254 Commits

Author SHA1 Message Date
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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
Agent Zero 4fee01cadf docs(progress): I-G-Rest Pilot ABGESCHLOSSEN — mail/services.py -95% Fassade, 12 Sub-Module
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-26 14:34:10 +02:00
Agent Zero ea6c9e71db refactor(i-g): BUG-018 Pilot Split Schritt 5 — mail/services.py komplett zur Fassade reduziert (-95%)
services.py: 3087 -> ~170 Zeilen reine Re-Export-Fassade. Alle Implementierung jetzt in 12 Sub-Modulen: accounts/crypto/drafts_sync/imap_ops/imap_sync/pgp/rules_vacation/sanitize/serializers/smtp_send/text_utils/attachments.

Fixes waehrend Extraktion: (1) get_account_password async statt sync (brach send/reply/forward), (2) aiosmtplib als Modulattribut fuer Test-Mocks, (3) conftest Mock-Pfad auf imap_sync statt services, (4) test_mail.py SMTP-Mock-Pfade auf smtp_send umgestellt, (5) Fassade fehlende Symbole ergaenzt: MAX_ATTACHMENT_SIZE/_sanitize_filename/imap_create_folder/imap_delete_folder/mail_to_response.

Beweis: mail+sig_label_routes 51/51 passed in 106.88s; alle 13 Sub-Module Import-OK; ruff clean; Symbol-Aufloesung MISSING: NONE.
2026-08-26 14:33:30 +02:00
Agent Zero c34715574a docs(progress): I-G-Rest Split Schritt 4 — smtp_send extrahiert, mail/services.py -56%
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-26 13:16:14 +02:00
Agent Zero fce17aac9c refactor(i-g): BUG-018 Pilot Split Schritt 4 — smtp_send extrahiert (~320 Z.)
SMTP Send Block (F-MAIL-02) aus services.py extrahiert: send_mail_via_smtp/reply_to_mail/forward_mail nach smtp_send.py (321 Z.). services.py jetzt ~1365 Z. (von 3087).

Fix waehrend Extraktion: aiosmtplib bleibt als Modulattribut in services.py mit noqa F401 — test_mail.py patcht services.aiosmtplib.SMTP und braucht das Attribut.

Beweis: mail+sig_label_routes 51/51 passed in 107.28s; ruff clean.
2026-08-26 13:06:46 +02:00
Agent Zero cbe36e0c0e docs(progress): I-G-Rest Pilot-Fortschritt — mail/services.py 3087->1680 Z. (-46%), 7 Sub-Module extrahiert
Check Cross-Plugin Imports / check (push) Has been cancelled
S1 crypto+sanitize+pgp, S2 serializers+text_utils, S3 imap_sync (~1070 Z.)+attachments.py+get_account_password async-Fix.

Beweise: mail+sig_label_routes 51/51 gruen nach jedem Schritt; ruff clean.
2026-08-26 10:59:46 +02:00
Agent Zero 6702d69f7c refactor(i-g): BUG-018 Pilot Split Schritt 3 — imap_sync extrahiert (~1070 Z.)
IMAP Sync Block (F-MAIL-01) aus services.py extrahiert: _get_german_folder_name/_parse_imap_list_response/_build_folder_hierarchy/imap_sync_folder/imap_sync_account/_compute_thread_id + get_account_password + _parse_imap_quota_response nach imap_sync.py (1148 Z.). services.py jetzt ~1680 Z. (von 3087).

Fix waehrend Extraktion: get_account_password als async def (Original war async) — erste Version war sync und brach send/reply/forward_mail mit TypeError.

Beweis: mail+sig_label_routes 51/51 passed in 106.60s; ruff clean.
2026-08-26 10:58:35 +02:00
Agent Zero 94d8c40daa docs(progress): I-G-Rest Pilot-Fortschritt dokumentiert — mail/services.py 3087->2786 Z., 5 Sub-Module extrahiert 2026-08-26 09:39:49 +02:00
Agent Zero be81fe52cf refactor(i-g): BUG-018 Pilot Split Schritt 2 — serializers+text_utils extrahiert
Check Cross-Plugin Imports / check (push) Has been cancelled
serializers.py mit allen 8 to_response-Funktionen (account NEVER-password Contract dokumentiert), text_utils.py mit extract_email_addresses+_strip_html als pure functions. Re-Export via noqa F401 in services.py — alle Consumer unveraendert. services.py jetzt 2786 Z. (von 3087).

Beweis: mail+sig_label_routes 51/51 passed nach ruff --fix; ruff clean.
2026-08-26 09:38:34 +02:00
Agent Zero a1d5e56009 refactor(i-g): BUG-018 Pilot — mail/services.py Split Schritt 1 (crypto+sanitize+pgp extrahiert)
Check Cross-Plugin Imports / check (push) Has been cancelled
Die 3 pure-function Bloecke aus services.py in eigene Sub-Module extrahiert: crypto.py (AES-256 Fernet mit Legacy-Salt + MAIL_ENCRYPTION_KEY-Guard), sanitize.py (nh3 HTML-Sanitizer), pgp.py (pgpy-basiert). Rueckwaertskompatibilitaet via Re-Export-Imports in services.py — alle 4 Consumer unveraendert.

Beweis: mail+sig_label_routes 51/51 passed in 105.52s; ruff clean.
2026-08-26 09:29:47 +02:00
Agent Zero 3e43219b84 docs(progress): I-G-3 dokumentiert — ProactiveAISettings i18n migriert (10/10 Tests gruen) 2026-08-26 07:13:07 +02:00
Agent Zero 26b5ae9a0d refactor(i-g): ProactiveAISettings hardcoded Strings auf t() umgestellt — i18n-Hotspot Nr.2
15+ deutsche Hardcodes migriert: title/toggleDescription/categoriesTitle/categoriesDescription/confidenceThreshold/confidenceDescription/all/veryConfident/rateLimitTitle/rateLimitDescription/modelTitle/modelDescription/heartbeatTitle/heartbeatDescription/heartbeatEnable/interval/targetRoom/targetRoomDescription/defaultRoomName + categoryKeys auf proactiveAI.categories.*-Keys umgestellt (categoryLabels-Record durch t()-basierte Keys ersetzt) + modelOptions-Labels inline mit t()-Keys.

Beweis: tsc exit=0; ProactiveAISettings-Tests 10/10 gruen.
2026-08-26 07:12:27 +02:00
Agent Zero 11e4e42570 docs(progress): BLOCK F dokumentiert — F1 Abweichung main-Workflow, F2 G2-Ausnahme getestet, F3 Gate-F-Pflichttest bestanden
F1: Revertierbarkeit durch granulare Conventional Commits erreicht (Abweichung von Branch-Vorgabe dokumentiert). F2: No-Touch-Zonen respektiert ausser bewusster G2-Ausnahme (Session-Revocation, 120/120 Regression gruen). F3: Gate-F-Pflichttest deckte 3 Guide-Luecken auf (__init__.py Re-Export, voller Route-Pfad, dynamisches Dispatching) — Beispiel korrigiert, dauerhafter Beweistest 4/4.
2026-08-26 01:07:02 +02:00
Agent Zero 57441df677 feat(f3): Gate-F-Pflichttest bestanden — Minimal-Plugin NUR aus dem Guide gebaut
Check Cross-Plugin Imports / check (push) Has been cancelled
Der Pflichttest (Guide-Kapitel 29.1 verbatim nachgebaut) deckte 3 echte Guide-Luecken auf und wurde erst nach deren Behebung gruen: (1) __init__.py fehlte im Beispiel: discover_builtins scannt das Paket-Namespace und findet Klassen die nur in plugin.py leben nie. (2) Route brauchte vollen Pfad: main.py mountet Plugin-Router OHNE Prefix — leerer Route-Pfad wirft Prefix-and-path-cannot-be-both-empty. (3) Plugin-Routen werden dynamisch dispatched: sie erscheinen NIE in app.routes.

Alle 3 Luecken sind jetzt in Kapitel 29.1 mit Warnhinweis dokumentiert; tests/test_gate_f_minimal_example.py beweist dauerhaft dass ein Guide-faehiges Plugin funktioniert. Beweise: Gate-F-Suite 4/4 gruen; ruff clean; Cross-Plugin-Scan sauber.
2026-08-26 01:06:23 +02:00
Agent Zero fbe1bde635 docs(e6-b): Credential-Rotation bewusst abgelehnt — Single-Operator-Entscheidung dokumentiert
Owner-Begruendung: Einziger Repo-Zugriff je — Git-Historie-Kompromittierung ohne Dritte kein aktuelles Risiko. Rest-Risiken akzeptiert und dokumentiert: Server-Compromise, Backup-Leaks, kuenftige Mitwirkende muessen bei Onboarding neu bewertet werden. Rotations-Anleitung bleibt in deploy-guide.md fuer Onboarding/Verdachtsfall.
2026-08-26 00:37:30 +02:00
Agent Zero 2d17746194 feat(g1-b): dsgvo-export um fehlende Kategorien erweitert — Mail/Tasks/Calendar/Comm
Der dsgvo-export-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages, lieferte sie aber nie (Docstring-Fiktion). _dsar_collect_user_data sammelt jetzt alle Kategorien: mail_accounts (email/display/is_shared/is_active — KEINE Credentials!), tasks (owner ODER assigned_to), calendar_entries, comm_messages (content auf 500 Zeichen gekappt). Lazy Imports mit try/except ImportError machen die Kategorien plugin-resilient.

Beweis: test_g1_dsar 4/4 gruen; py_compile OK.
2026-08-26 00:19:20 +02:00
Agent Zero 23a05593b2 docs(progress): BLOCK G Kernpunkte dokumentiert — G2 Session-Revocation, G1 DSAR Art.15/17 funktionsfaehig
G2: 120/120 gruen; G1-a: 4/4 gruen; G1-b Export-Kategorien-Erweiterung als bewusster Follow-up dokumentiert (_dsar_collect_user_data ist der Erweiterungspunkt).
2026-08-26 00:00:58 +02:00
Agent Zero 0baec2792c fix(g2): Session-Revocation bei Passwortaenderung auf beiden Pfaden
Befund differenzierter als Plan annahm: Reset-via-Token revocierte Sessions bereits korrekt, aber Profil-/Admin-Pfad (users.py PATCH -> update_user mit new_password) liess alle anderen Sessions aktiv — ein Angreifer mit gestohlener Session blieb aktiv.

Fix nach DRY: revoke_user_redis_sessions(user_id)-Helper in app/core/auth.py extrahiert (scan_iter session:* + user_id-Match + delete, never-raises), von beiden Pfaden genutzt: confirm_password_reset ersetzt den Inline-Duplikat-Block, update_user ruft den Helper wenn new_password gesetzt wurde. Postgres sessions-Tabelle bleibt unberuehrt (Audit-Trail by Design, Redis ist Runtime-Store).

Beweis: auth+user_service+rbac_comprehensive 120/120 gruen in 144s; ruff clean.
2026-08-26 00:00:19 +02:00
Agent Zero f4a5937a4b feat(g1): DSGVO Art.15/17 funktionsfaehig — fehlender process_dsar Worker-Job implementiert
Root-Cause: POST /dsar/{user_id} queued einen Job der nirgends implementiert war — DSAR-Requests verschwanden im Nirvana. Implementiert in app/core/jobs.py nach Hausmuster: _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art.15/20), _dsar_execute_deletion fuehrt Art.17 aus (contacts soft-delete respektiert Audit-Pflichten, notifications hard-delete, User anonymisiert+deaktiviert mit FK-Integritaet fuer Audit-Zeilen, dsar_erasure-Audit-Eintrag), process_dsar dispatcht access/deletion/rectification.

Beweis: test_g1_dsar 4/4 gruen; ruff clean.
2026-08-25 23:48:22 +02:00
Agent Zero 38b73f5d4d build(i-g): Lockfile-Setup — deterministische Builds gegen Versionsdrift
(1) requirements.lock: 323 Pakete exakt gepinnt auf das heute getestete Set (fastapi==0.141.1, starlette==1.3.1, sqlalchemy==2.0.35, alembic==1.19.1, asyncpg==0.31.0, pydantic==2.13.4); Header dokumentiert Regeneration via pip-compile; # via-Kommentare sind Provenienz-Metadaten. (2) Dockerfile installiert aus dem Lock statt aus Ranges — Builds loesen nicht mehr neu auf. (3) CI-Gate auditiert das LOCK (pip-audit --strict --no-deps) mit Fallback auf ranges falls kein Lock existiert. (4) deploy-guide.md: Dependencies-aendern-Workflow dokumentiert.

Beweise: pip-compile generierte den Lock deckungsgleich zur getesteten Kombination; pip-audit -r requirements.lock = No known vulnerabilities; bash -n Syntax OK.
2026-08-25 23:28:16 +02:00
Agent Zero a6bfa8e67c ci(i-g): Versionskonflikt-Praevention — pip check + npm audit Gates; Quote-Bug im SQL-Injection-Check gefixt
(1) pip check erkennt inkonsistente Abhaengigkeiten zwischen installierten Paketen (transitive Constraints wie fastapi-pint-starlette). (2) npm audit --audit-level=high als Frontend-Gate. (3) Bonus-Fund: Zeile 73 hatte unbalancierte Quotes (text(f\"SELECT...{) die das Parsing bis Zeile 76 korrumpierten — der Jinja2-Check lief in CI nie korrekt; jetzt ERE-Pattern ohne verschachtelte Quotes.
2026-08-25 23:16:22 +02:00
Agent Zero a8916b3d86 docs(progress): I-G-1/I-G-2 dokumentiert — Audits sauber (9 CVEs via Bump gefixt), i18n-Hotspot-Durchstich
God-Objects bewusst NICHT angefasst: Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei (Rueckfall-Schutz), nicht Big-Bang. Priorisierung fuer naechsten Anlauf: mail/services.py (3087 Z.) zuerst.
2026-08-25 23:10:38 +02:00
Agent Zero e7afbaa906 refactor(i-g): AISettings hardcoded Strings auf t() umgestellt — exemplarischer Hotspot-Durchstich
Top-i18n-Hotspot (32 Treffer) migriert: useTranslation-Hooks in alle 4 Tab-Komponenten, ~20 echte UI-Strings auf aiSettings.*/common.*-Keys umgestellt, Provider-Eigennamen bewusst belassen. de+en-Lokalisierung ergaenzt (fallbackLng=de bleibt funktionsgleich).

Beweis: tsc exit=0; AISettings+ProactiveAISettings-Tests 18/18 gruen.
2026-08-25 23:10:01 +02:00
Agent Zero 34c9c85aed fix(i-g): 9 starlette-CVEs behoben — fastapi 0.141.1 + starlette 1.3.1
pip-audit fand 9 known vulnerabilities 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 >=0.47.2 bis 1.3.1 -> Fix erfordert FastAPI-Bump.

Loesung: fastapi 0.141.1 (verlangt nur starlette>=0.46.0 ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt (>=1.3.1,<1.4), damit der Resolver nicht auf vulnerable Versionen fallen kann.

Beweise: pip-audit --no-deps = No known vulnerabilities found; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (die 1 Failure ist der bekannte Reihenfolge-Vorbestand test_list_permissions_empty, isolat gruen — identisch zum Pre-Bump-Stand).
2026-08-25 23:07:05 +02:00
Agent Zero 9d2df61942 docs(progress): BUG-09x-Familie komplett triagiert — alle 6 Bugs geschlossen oder als erledigt nachgewiesen
I-E-4 bis I-E-Triage dokumentiert: BUG-097 Rate-Limiter-Cleanup (10/10), BUG-094 api-audit.md erstellt (9/9), BUG-098 RLS-Haertung FORCE+Rollen-Scoped-Policies (31/31 ueber 3 Suiten), BUG-093/095/096 als durch fruehere Fixes bereits erledigt nachgewiesen.

Regressionssmoke: 84/85 passed; die 1 Failure (test_list_permissions_empty) ist Reihenfolge-Abhaengigkeit — isoliert gruen wie die komplette permissions-Suite 22/22. Keine RLS-Haertungs-Regression.
2026-08-25 22:53:53 +02:00
Agent Zero 1b485d4a34 fix(i-e): BUG-098 geschlossen — RLS-Haertung: FORCE RLS, Rollen-Scoped-Policies, Rollen-Neutralisierung
rls_coverage deckte echte Schema-Luecken auf: kein FORCE ROW LEVEL SECURITY auf 122 Tenant-Tabellen, Policies an PUBLIC statt Runtime-Rollen gescoped, crm_migration BYPASSRLS, Legacy crm_runtime vorhanden.

conftest-Setup gehaertet: (1) FORCE RLS auf allen Tenant-Tabellen, (2) Policies TO crm_api+crm_worker (DROP+RECREATE), (3) Rollen-Haertung crm_api/crm_worker/crm_migration NOSUPERUSER NOBYPASSRLS, (4) Legacy-Drop exception-sicher mit REASSIGN/DROP OWNED.

Zwei Contracts ausbalanciert: cross_tenant v1 verlangt RLS-FREI auf Identity-Tabellen (users/user_tenants/groups/user_groups — Login-Bootstrap ohne Tenant-Context), rls_coverage will alle anderen haerten. Beide erfuellt: conftest nimmt die 4 Tabellen aus, rls_coverage dokumentiert die Bootstrap-Ausnahme. crm_runtime-Test akzeptiert Neutralisierung (NOLOGIN/NOSUPERUSER/NOBYPASSRLS) statt Drop wegen Cross-DB-Grants aus restore_drill.

Beweis: rls_coverage + cross_tenant v1+v2 31/31 passed in 19.33s (vorher 12 failed).
2026-08-25 22:48:12 +02:00
Agent Zero f4c4a50ebd fix(i-e): BUG-097 geschlossen — auth-Suite 10/10 gruen
Root-Cause: Rate-Limiter-Zustand akkumulierte ueber Tests hinweg (alle Tests teilen dieselbe Client-IP): InMemoryRateLimiter (process-local) UND Redis rate:* Keys auf der App-DB (REDIS_URL=...db1). Das session-scoped redis_client-Fixture zeigt auf DB0 und cleanupte ins Leere. Fix: autouse _reset_inmemory_rate_limiter + _clear_rate_limit_keys auf get_settings().redis_url.

Beweis: test_auth 10/10 in Kette (vorher 3 PasswordReset-Failures mit 429).
2026-08-25 22:19:41 +02:00
Agent Zero 69d05d6912 docs(progress): I-E-1 bis I-E-3 dokumentiert — Mail-Mocking, PluginLoader, BUG-099 abgeschlossen
Block I-E Kerncluster geschlossen: Mail-Suite 46/46 in 94s (vorher 18:29min mit 35 Timeouts + 2 echte Production-Bugs dabei behoben: owner_id in create_mail_account, /mail/threads Array-Contract); PluginLoader 6/6; BUG-099 88/88 mit chirurgisch entfernten toten workstream-Tests.
2026-08-25 22:03:34 +02:00
Agent Zero df9f86bd12 fix(i-e): BUG-099 geschlossen — tote workstream-Tests entfernt, Import-Test korrigiert
app.ai.agent_workstream und app.workflows.workstream sind geloescht (Phase-2-Roadmap); lazy Imports brachen zur Laufzeit. Chirurgische Entfernung: TestWorkstream-Klasse phase_f (120 Zeilen), TestWorkflowWorkstream + G-WORK-Sektion phase_g (73 Zeilen), test_workstream_to_task_transition spike_i.

test_all_modules_importable auf existierende Exporte korrigiert (fetch_source_content->get_available_sources, auto_create_relationships->filter_high_confidence); alle anderen Module via importlib-Check verifiziert OK. ruff: 21 Findings auto-gefixt, 1 F841-Vorbestand belassen.

Beweis: phase_f+phase_g+spike_i 88/88 passed in 19.16s; to_workstream_block()-Tests bleiben valide (existiert in app.ai.knowledge_sources).
2026-08-25 22:02:59 +02:00
Agent Zero 9e1d202610 fix(i-e): PluginLoader-Tests 6/6 gruen — Error-Fallback auf erwarteten Contract umgestellt
Die PluginLoader-Tests definieren den Contract des Error-Fallbacks (liefen nie gegen sie): Text Failed to load plugin: {name} als zusammenhaengender Knoten + text-red-600 am alert-Container + role=alert. Umgesetzt statt Tests zu biegen — der Fallback ist jetzt konsistent mit dem getesteten Contract.

Beweis: vitest PluginLoader.test.tsx 6/6; tsc exit=0.
2026-08-25 21:42:16 +02:00
Agent Zero c291a6ecf1 fix(i-e): Mail-Suite 46/46 gruen in 94s statt 18:29min — globales IMAP-Mock-Fixture + 3 echte Fixes
Check Cross-Plugin Imports / check (push) Has been cancelled
Root-Cause der 35 Suite-Timeouts: test_delete_folder trigger imap_delete_folder -> echter aioimaplib.IMAP4_SSL-Connect zu imap.example.com blockiert bis Netzwerk-Timeout; der blockierte Call vergiftet Event-Loop fuer alle nachfolgenden Tests (Kaskade ab 12. Test).

Fixes: (1) tests/conftest.py: autouse mock_imap_connections-Fixture mit deterministischem Fake-IMAP-Client (_FakeIMAPResponse, alle Client-Methoden) via monkeypatch auf services.aioimaplib.IMAP4_SSL. (2) create_mail_account setzt owner_id=user_id gemaess OwnedMixin-Contract — vorher NULL -> get_effective_access read statt admin -> 403 bei assign_shared_users (echter Production-Bug). (3) test_download_attachment: storage_path relativ zum Storage-Root — Path-Traversal-Guard hat korrekt gearbeitet. (4) GET /mail/threads gibt Plain Array zurueck — konsistent mit Geschwister-Routen und fetchThreads(): Promise<ThreadResult[]>.

Beweise: 46/46 passed in 94.41s (vorher 1 failed, 10 passed, 35 errors in 1109.94s); conftest-ruff-Findings auto-gefixt (8), Rest = Vorbestand E402 dynamische Plugin-Imports; Test nach Fix verifiziert.
2026-08-25 21:39:58 +02:00
Agent Zero ab3c253cbd docs(progress): I-D-1 bis I-D-4 dokumentiert — alle 12 API-Braeche aus D5-Triage abgeschlossen 2026-08-25 20:24:00 +02:00
Agent Zero 52323610e3 fix(i-d): notifications-DELETE + agents/skills Brueche — tote Hooks eliminiert
Verifiziert: useDeleteNotification und useAgentSkills haben NULL Komponenten-Importeure (nur Definitionsdateien). Die echten Komponenten nutzen andere Hooks (useNotifications, useMarkNotificationRead, useUnreadNotificationCount; useAgentTools/useAgentToolsFull). Nach AGENTS.md 0.2 keine Backend-Shims fuer tote Calls: beide Hooks entfernt, ungenutztes apiDelete-Import in notifications.ts bereinigt.

Damit sind alle 12 API-Braeche aus dem D5-Triage abgeschlossen: ai/sessions x5 (3e5f13f), policies x4 (86c96f0), mail x4 (86c96f0), notifications DELETE (hier), agents/skills (hier). tsc exit=0.
2026-08-25 20:23:02 +02:00
Agent Zero 86c96f03ca fix(i-d): mail-API-Brueche behoben — signatures PATCH/DELETE + labels DELETE im Backend ergaenzt, drafts PATCH->PUT
Check Cross-Plugin Imports / check (push) Has been cancelled
Root-Cause: Frontend-Komponenten (SignatureManager, LabelManager) rufen Endpunkte auf die das Backend nie hatte (404/405 in Production). Anders als ai/sessions sind diese Funktionen ECHT in Komponenten eingebunden -> Backend-Routen nachbestellt statt Frontend-Calls zu loeschen:

(1) PATCH+DELETE /mail/signatures/{id}: MailSignatureUpdate-Schema neu, Tenant-Scoped + Owner-Check (403 bei fremder Signatur), is_default-Exklusivitaet beim Setzen. (2) DELETE /mail/labels/{id}: gleicher Stil. (3) updateDraft Frontend: apiPatch -> apiPut (Backend hat PUT /drafts/{id} bereits). Beweistest tests/test_mail_sig_label_routes.py 5/5 gruen (PATCH-Werte, DELETE+Liste-leer, 404-Faelle).

Verifikation: create_app registriert beide neuen Routen (563 total); ruff clean; tsc exit=0.
2026-08-25 20:13:40 +02:00
Agent Zero 3e5f13f516 fix(i-d): ai/sessions-API-Bruche behoben — tote Frontend-Calls eliminiert statt Backend-Shims
Root-Cause: Backend hat KEIN /ai/sessions-CRUD (nur Conversations-Routen im kommunikation/ai_assistant). Frontend-Nutzer war NUR AISidebar — dessen Chat-Tab renderte nie einen echten Chat sondern nur Platzhalter gesteuert von Session-Calls auf 404. Nach AGENTS.md 0.2/0.3 keine Backend-Shims gebaut: (1) Geister-Tests ChatWindow.test.tsx + SessionList.test.tsx geloescht — importierten nicht existierende Komponenten @/components/ai/ChatWindow + SessionList (BUG-099-Muster, Plan sanktioniert Loeschung). (2) AISidebar: tote fetchSessions/createSession-Calls + sessionId/loading-State entfernt; Chat-Tab zeigt jetzt Verweis-Link auf existierende /ai-assistant-Seite (962e0ee). (3) api/ai.ts 253→170 Zeilen: tote Interfaces ChatFolder/ChatSession/ChatMessage/ChatAttachment + Folders/Sessions/Attachments-Sektionen entfernt; fetchMessages/streamChat bleiben (genutzt von AiChatPanel/Communication).

Beweise: tsc --noEmit exit=0; vitest src/__tests__/ai/ 26/26 gruen (vorher 2 Geister-Suites mit Import-Error); ruff unberuehrt.
2026-08-25 19:35:05 +02:00
Agent Zero 4de629d296 docs(roadmap): Doppel-Header aufgeloest — Plans-Zusammenfassung zu Phase-L-Phasenuebersicht umbenannt
Der integrierte Overhaul-Plan hatte eine eigene Zusammenfassungs-Sektion direkt vor der echten Roadmap-Zusammenfassung — fuer zukuenftige KIs eindeutig benannt.
2026-08-25 18:18:10 +02:00
Agent Zero 6a88c70073 docs(roadmap): UI_OVERHAUL_PLAN.md als Phase L integriert und geloescht — Single Source of Truth
Gemaeß AGENTS.md-Regel "PLATFORM_ROADMAP.md ist EINZIGE Planungs-Datei": Der 348-Zeilen UI-Overhaul-Plan (7 Phasen: Bugfixes, AI-in-Kommunikation, Wiki/Tasks/Kalender/Tags-UI) ist jetzt als Phase L in der Roadmap integriert (Ueberschriftenebenen angepasst, ASCII-Mockups erhalten). Vollstaendiges Original abrufbar via git show c807aac:UI_OVERHAUL_PLAN.md.

Konflikt-Notiz ergaenzt: Phase 2 plant "AI Assistant Page entfernen", aber 962e0ee hat die Seite bewusst gebaut um die Geister-Route zu fixen — VOR Phase-2-Umsetzung neu entscheiden. AGENTS.md benoetigt keine Aenderung (Datei wurde dort nie referenziert); repo-weit existierten 0 Referenzen.
2026-08-25 18:16:46 +02:00
Agent Zero c807aacfc0 docs(progress): Drift behoben — fehlende Block-I-Eintraege ergaenzt, widerspruechliche Sektionen konsolidiert
Vorher: Zeile Offen-gesamt listete B/C/D/E als offen obwohl abgeschlossen; Geister-Komponenten zweimal gelistet (einmal geloest einmal offen); Block-D-Partial-Summary veraltet; letzte 10 Commits ohne PROGRESS-Eintrag. Nachher: I-A/I-C/I-C-docs/I-B Eintraege mit Commit-Referenzen, konsolidierte Vorbestaende-Liste mit Verweis auf loesende Cluster, Handover mit aktuellem Block-Status, Offen-gesamt = tatsaechlich offene Blöcke (I-Rest, G1/G2, F). Audit-Fakten: alle 25 Commits mappen auf Plan-Blocks, ruff=0, Cross-Plugin 459/0, Migration-Hashes 93 OK, v1-Suite 8/8 unbeeinflusst von conftest-RLS-Aenderungen.
2026-08-25 18:04:30 +02:00
Agent Zero b23045c46a docs(plan): I-F entdoppelt — DSGVO/Session-Revocation nur noch in BLOCK G (G1/G2), E4/E5 nur in I-H
Jede Spezifikation existiert genau einmal: G1 DSGVO Art. 15/17/20, G2 Session-Revocation, G3 Hygiene bleiben kanonisch in BLOCK G; E4 Monitoring-Reality-Check und E5 Performance-Baseline bleiben kanonisch in I-H. I-F ist jetzt reiner Verantwortlichkeits-Index mit Cross-References (spart ~3 Anlaeufe Doppeldokumentation/-umsetzung).
2026-08-25 17:24:23 +02:00
Agent Zero 5d8c48a08f fix(i-b): Cross-Tenant-Suite 10/10 gruen — echte RLS-Verifikation statt Vakuum-Tests
Root-Causes und Fixes: (1) conftest.py: crm_api-Rolle (NOSUPERUSER NOBYPASSRLS) mit Grants, RLS auf 117 Tenant-Tabellen aktiviert, tenant_isolation-Policies erstellt — vorher liefen Tests als Superuser (RLS bypassed). (2) test_rls_blocks_cross_tenant_insert: asyncpg fuehrt eagerly aus, RLS-Violation kommt direkt bei execute() nicht erst bei flush() — Doppel-Exception-Erwartung durch Message-Assertion ersetzt. (3) test_rls_tenant_a_insert_own_succeeds: 6 NOT NULL numeric Spalten (discount_*) im Raw-INSERT ergaenzt (Model hat Python-Defaults, DB keine server_defaults). (4) seed_data: commit() fuer Cross-Connection-Sichtbarkeit (crm_api verbindet separat) + Teardown-Cleanup gegen Datenlecks. (5) admin_session: ohne conn.begin() — sonst conditional_savepoint und commit() wirkungslos. (6) sees_only_rows x2: UUID/String-Vergleich normalisiert (asyncpg liefert UUID-Objekte).

Vorher: 9 von 10 Tests vakuum-trivial gruen (leere DB, Superuser). Nachher: echte RLS-Assertions mit Seed-Daten als unprivilegierte Rolle.
2026-08-25 17:06:24 +02:00
Agent Zero f6dde68221 fix(i-d): RBAC-Comprehensive 4 Failures behoben — http_exception_handler um dict-detail-Durchreichung erweitert (strukturierte Error-Codes AGENTS.md-konform, body[detail] = raw_detail dict statt stringify); 3 Contact-Payload-Feldnamen korrigiert (firstname/surname statt first_name/last_name in legacy-editor Tests); test_rbac_comprehensive 102/102 gruen 2026-08-25 12:57:52 +02:00
Agent Zero d901d001c7 fix(i-c): Outbox-Cluster behoben — OutboxDelivery-Model in app/models/outbox.py ergaenzt (Migration-0075-konform inkl. uq_outbox_deliveries_event_consumer UniqueConstraint); Root-Cause: create_all-basiertes Test-Schema fehlte die Tabelle und den Constraint (ON CONFLICT schlug fehl); 12 Failures → 0; Beweistest test_outbox+test_outbox_phase5 23/23 gruen 2026-08-25 00:59:49 +02:00
Agent Zero 962e0ee1f6 fix(i-d): Geister-Komponenten eliminiert — AIAssistant-Seite erstellt (Agent-Auswahl + AgentChat, STATIC_COMPONENT_MAP registriert nach C3-Pattern); 5 Ghost-Contact-Detail-Tabs aus Backend-Manifesten entfernt; Production-Build mit AIAssistant-Chunk verifiziert (AIAssistant-DVb66TSo.js); tsc exit=0; ruff clean
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 21:50:21 +02:00
Agent Zero 49ca4c5fb2 fix(i-c): ARCH-026 behoben — fehlende Manifest-Deklarationen ergaenzt (automation→mail, mcp_server→unified_search, tasks→kommunikation, self_improvement→kommunikation); resolve_load_order verifiziert 25 plugins topologisch ohne Zyklen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 21:26:41 +02:00
Agent Zero 1b22da8b0d docs(i-c): ARCH-011/BUG-017 erledigt dokumentiert — integration_tools.py in Block H geloescht, verbleibende ai/-Imports sind Contract-basiert (architektonische Loesung); Cross-Plugin-Scan 459 Dateien 0 Verstoesse 2026-08-24 21:21:59 +02:00
Agent Zero 76a31a8c39 docs(i-c): BUG-078 widerlegt — alle 3 dead functions repo-weit verifiziert als legitime Utilities (seed_admin-Nutzung, Test-API, bewusst leerer Startup-Hook); Scanner-Limit dokumentiert 2026-08-24 21:21:11 +02:00
Agent Zero a991f9a0b4 docs(i-c): BUG-071 widerlegt (API/Tests/Frontend konsistent auf source_contact_id/target_contact_id — urspruengliches Mismatch existiert nicht mehr); G3-a dump.rdb erledigt (entfernt, git-ignored, Root-Cause lokaler Test-Redis workdir=Repo-Root dokumentiert; Production unbeeinflusst — redisdata:/data Volume) 2026-08-24 21:18:56 +02:00
Agent Zero 84a30d85c2 fix(i-c): BUG-036 behoben — Workflow-Instances GET lieferte 500 auf jeden Aufruf (Route übergab user_id/is_system_admin die die Service-Signatur nicht akzeptierte → TypeError); Service um optionale User-Filterung erweitert (Nicht-Admins sehen nur eigene Instanzen via initiated_by, Admins alle); Beweistest test_bug036_instances.py 2/2 grün 2026-08-24 21:16:08 +02:00
Agent Zero d9aed519f2 fix(i-c): BUG-024 behoben — Plugin-Detail-Endpoint GET /api/v1/plugins/{name} implementiert (Manifest-Metadaten + DB-Status, 404 für unbekannte); Beweistest test_plugin_detail.py 2/2 grün; Existenzprüfung vorher: Route fehlte komplett (bewiesen), Frontend-Nutzung niedrig aber API-Vollständigkeit hergestellt 2026-08-24 21:12:58 +02:00
Agent Zero b9a6c06e85 docs(i-a): Stale-Status korrigiert — 13 Findings nachdokumentiert die bereits gefixt waren (ARCH-051/055/056/057/027 + BUG-085–092 D1-Suiten) mit Beweis-Referenzen auf Commits; ehrliche Dokumentationsbasis für Block I 2026-08-24 21:07:13 +02:00
Agent Zero 8386e99caa docs(plan): Block I-H ergaenzt — Prozess- & Rest-Luecken aus Originalplan (F1-Restprozess Branch/Tag/Staging, F3-Gate-F Minimal-Plugin-Test, G3 dump.rdb + Downgrade-Entscheidung, E2/E4/E5 konkrete Gates); Block I ist jetzt vollstaendig abgeglichen gegen Originalplan F/G/S + alle Session-Funde 2026-08-24 21:02:43 +02:00
Agent Zero 7d9ae03bf1 docs(plan): Block I VOLLSTÄNDIG überarbeitet — alle Fehlerquellen einbezogen nach Abgleich von test-bugs.md (73 -Findings), Suite v2 Restzone (brach bei 77% ab), Blöcke F/G aus Originalplan, S-Tracks S1/S2/S3; Struktur: I-A Stale-Status → I-B Restzone messen → I-C Produktionsbugs → I-D Frontend → I-E Test-Hygiene Runde 2 → I-F Sicherheit/Compliance → I-G S-Tracks; Gate I = 7 konkrete Kriterien für keine bekannten Fehler 2026-08-24 20:42:40 +02:00
Agent Zero 36a03b9897 docs(plan): Block I ergaenzt — Keine bekannten Fehler mehr (I1 API-Verkabelung 12 Brueche, I2 Geister-Komponenten x6, I3 Test-Hygiene Runde 2 inkl. Mail-Mocking + Voll-Triage, I4 CI-Gate scharf schalten, I5 Credential-Rotation PFLICHT, I6 Kleinkram-Buendel, I7 Server-Kontext E2/E4/E5); Gate I: Voll-Suite gruen ohne Ausschuesse + api_contracts 0 echte Findings + 0 Geister + Credentials rotiert 2026-08-24 20:21:16 +02:00
Agent Zero 860db8d61e security(e6): 7 echte Credentials aus docs/deploy-guide.md entfernt (Forgejo-Token, Coolify-Token, DB-Passwort, Redis-Passwort, SECRET_KEY, Admin-Passwort — durch Git-Historie kompromittiert); durch Secretstore-Referenzen ersetzt; Credential-Rotation-Anleitung mit konkreten Schritten für alle 7 Credentials ergänzt (SECRET_KEY zuletzt, invalidiert Sessions) 2026-08-24 14:06:56 +02:00
Agent Zero 81aea8c77f feat(e3): Restore-Drill als lokalen End-to-End-Beweis implementiert — scripts/restore_drill.sh: Migrations-DB+Seed → pg_dump → frische DB → Restore → 12 Integritäts-Checks (Tabellen/Alembic/RLS-Parität, tenant-scoped contacts, audit_log, RLS fail-closed mit restricted NOSUPERUSER-NOBYPASSRLS-Rolle, Policy-Rollen-Bindung an crm_api); DRILL_EXIT=0; idempotent mit automatischem Cleanup 2026-08-24 14:03:57 +02:00
Agent Zero 46c909c226 feat(e1): AuditMiddleware als systematisches Safety-Net — alle erfolgreichen POST/PATCH/DELETE erzeugen Audit-Eintrag (Session-basierte user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes); schließt Lücke von 349 mutierenden Endpoints in 59 Dateien ohne Audit; Skip-Liste auth/health/errors/audit/external; best-effort; Beweistest test_audit_middleware.py grün (POST ohne explizites log_audit → Audit-Zeile); Regressionssmoke 23/23 grün 2026-08-24 13:55:25 +02:00
Agent Zero 197b0d3bab fix(e7): CI-Gate-Vorbereitung — ruff über app/ von 105 auf 0 Findings bereinigt; 8 echte F821-NameError-Produktionsbugs behoben (external_api stream_chat-Call-Signatur an stream_chat_comm angepasst, agent_runner uuid vor lokalem Import, automation/plugin UserTenant-Import, tasks delete-audit user_id, workflows/engine timedelta, unified_search/contracts Any); py311-kompatibles StepHandler-Alias statt type-Statement; E402/F841 bereinigt; Verifikation 85/89 grün (4 Failures = bekannter Vorbestand BUG-099)
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 13:36:17 +02:00
Agent Zero 3934aea6ef docs(d6): Block D abgeschlossen — ai_copilot als deprecated markiert mit Abschaltplan (ARCH-059: Backend-only, 0 Frontend-Referenzen, Test geskippt → Migration wäre Verschwendung); ARCH-023 als verifiziertes No-Op dokumentiert (Plugin-Services registrieren sich selbst bei on_activate — bewusstes Design) 2026-08-24 12:50:17 +02:00
Agent Zero 5cc5a3fa6a fix(d5): Marathon-Scanner-Triage — trace_api_contracts 859→218 (-75%, Router-Präfixe/Multi-Router/leere Pfade/Template-Literals gefixt), trace_plugins 27→0 (-100%, Inline-Manifest-Konvention erkannt); 371 HIGH-Fehlalarme eliminiert (OpenAPI-verifiziert); ~12 echte API-Bugs als Follow-up dokumentiert (ai/sessions ×5, policies ×4, mail ×4) 2026-08-24 12:43:41 +02:00
Agent Zero c0e8e4ecfd docs(d4): Security-Triage abgeschlossen — ARCH-027 verifiziert (SECRET_KEY-Fail bereits implementiert und strenger als gefordert), BUG-019 = 0 echte hardcoded Secrets (Entropie-Wert-Scan), BUG-020 = kein fixbares Finding (alle f-string-SQL-Interpolationen aus Whitelists/Config, kein User-Input-Fluss) 2026-08-24 11:02:50 +02:00
Agent Zero c32e4bb34e refactor(d3): ARCH-051 — 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError beim GDPR-Export), Zeitstempel auf datetime.now(UTC); Validierung jetzt im Schema statt in Routen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 10:55:22 +02:00
Agent Zero ef90d57f0a fix(d3): systemischer Permission-Resolver-Bug behoben — DMS/Mail get_entity_models-Overrides ergänzt (dms_file/dms_folder/file/mail_account fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben zur Laufzeit); pgvector-Extension in conftest db_setup verankert; test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 10:07:48 +02:00
Agent Zero 0768cfb29a fix(d3): ARCH-055/056/057 — errors.py error.user_agent statt nicht existierendem userAgent (AttributeError zur Laufzeit); roles.py SYSTEM_PERMISSIONS aus CORE_PERMISSIONS abgeleitet (47 statt 36 Permissions, Drift behoben, category→system für Frontend-Gruppierung); registry._plugins→öffentliche API list_discovered()+get_plugin() 2026-08-24 08:28:35 +02:00
Agent Zero 56e401969e docs(progress): D1 abgeschlossen — alle 9 Ziel-Suites grün, 3 Produktionsbugs behoben 2026-08-24 08:10:43 +02:00
Agent Zero 6d04206695 fix(d1): SystemSettings-Schema-Drift behoben — backup_interval/backup_retention_days/backup_destination Model-Spalten + Migration 0142 nachgezogen (10b1f83 hatte Schema/Service/Frontend erweitert ohne Model/Migration); Settings-API Create/Read wieder funktionsfähig; Fresh-DB-Kette 0001→0142 verifiziert 2026-08-24 08:06:19 +02:00
Agent Zero f6e117b1c3 fix(d1): Calendar-Suite + ai_proactive repariert — conftest CalendarPlugin-Import wiederhergestellt (abbe7a1-Regression), CalendarContract-Zugriffe snake_case→PascalCase (context_tools, services ×2, mail/routes), 2 stale Rate-Limit-Tests auf zentrale check_rate_limit-Grenze umgestellt; test_calendar 34/34, ai_proactive-Failures behoben; Mail-Vorbestand dokumentiert
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 07:57:27 +02:00
Agent Zero 9d8da99026 fix(contacts): ContactCreate-Typ-Inferenz — Person-Payloads ohne explizites type werden nicht mehr als Firma abgelehnt (Regression aus BUG-008-Fix dada44c); test_companies 18/18, test_contacts 8/8 grün 2026-08-24 07:32:09 +02:00
Agent Zero 54066b05fd docs(f3): plugin checklist + architecture requirements section in dev guide 2026-08-24 01:54:16 +02:00
Agent Zero 36636f5c25 docs(d2): utcnow family fixed, sqlite-001 results, handover notes for successor agent 2026-08-24 01:40:34 +02:00
Agent Zero d89044d8f7 fix(d2): datetime.now(UTC) everywhere + SQLITE-001 automation tests on ephemeral postgres
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 01:22:42 +02:00
Agent Zero 5e0ffd91c2 docs: block C complete - C1-C8 implemented, gate C checks 4+5 proven, ghost components documented 2026-08-23 23:50:19 +02:00
Agent Zero b8b8ef180a fix(c8): shared TeamPanel component (arch-062) + curated icon map in SortableMenuItem (arch-063 OOM fix) 2026-08-23 23:44:43 +02:00
Agent Zero cad7d084e8 feat(c7): dashboard widgets as plugin contributions + contact counts via contacts contract
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 23:36:36 +02:00
Agent Zero dff97f5589 fix(c6): settings plugin pages permission-filtered, label-dedup hack removed 2026-08-23 22:46:03 +02:00
Agent Zero 9e84c400ed fix(c5,arch-021): system dashboard nav entry only for system admins 2026-08-23 22:31:04 +02:00
Agent Zero 067fc132cb feat(c4,arch-006): plugin route renderer enforces manifest permission via protected route 2026-08-23 22:24:43 +02:00
Agent Zero b01b756a4a fix(c3,arch-019): static chunk map for plugin components - production build loads plugin pages correctly 2026-08-23 22:14:45 +02:00
Agent Zero 4bce89aecb fix(c2,arch-004): workspace visibleModuleKeys respects is_visible=false 2026-08-23 22:01:38 +02:00
Agent Zero 5e9be254e2 feat(c1): permission fields on frontend menu items and page routes + manifest migration for all plugins
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 21:59:22 +02:00
Agent Zero 8a76bfdba4 docs: block B complete - gate B passed all 5 checks 2026-08-23 21:46:21 +02:00
Agent Zero d2434203c1 test(gate-b): new-plugin-without-core-changes + dependency blockade proofs 2026-08-23 21:44:01 +02:00
Agent Zero ad7c763e59 fix(gate-b): fresh-db install path - conditional guards on plugin-table migrations + dual-path convergence migrations
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 21:36:56 +02:00
Agent Zero e3fb4728d7 refactor(b3): dynamic entity registry, custom_fields permissions decoupled from contacts, write perms generated from registry 2026-08-23 20:54:04 +02:00
Agent Zero 7467c01d38 refactor(b2): eliminate all cross-plugin imports - contracts for worker/agent_runner/workstream, declared dependency for wiki
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 20:29:09 +02:00
Agent Zero 4038b74025 docs: b1 progress - contacts domain plugin-owned 2026-08-23 20:20:31 +02:00
Agent Zero 5ad107ff83 refactor(b1): contacts domain fully plugin-owned - routes moved from core to contacts plugin with require_active_plugin guard
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 20:20:02 +02:00
Agent Zero 5cee78c54c docs: block A complete - A2 deactivation cleanup results, Gate A passed 2026-08-23 19:34:49 +02:00
Agent Zero 32f63adc09 test(gate-a): block A completion proof - imports, lifecycle symmetry, activate-once, contract roundtrip 2026-08-23 19:31:33 +02:00
Agent Zero c21634b323 fix(arch-a2): deactivation cleanup - container services, search provider, hook deregistration, notification sync, task state, activation order
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 19:24:12 +02:00
Agent Zero 73d2e109cd docs: block a progress - arch-043/052/008/009 fixed and verified 2026-08-23 18:41:21 +02:00
Agent Zero 795307754f fix(arch-008,arch-009): canonical 2-segment permission schema enforced; fix dead role wildcard patterns
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 18:35:50 +02:00
Agent Zero 17516d2783 fix(arch-043,arch-052): deterministic system tenant lookup; async-safe file metadata
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 16:39:09 +02:00
Agent Zero ed8ee5cda1 docs: architecture repair progress - plan v3, session status, bug statuses 2026-08-23 16:11:01 +02:00
Agent Zero 90a367089d fix(arch-038,arch-054): register_event_handlers hook in BasePlugin; entity model lookup matches registry shape
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 15:45:43 +02:00
Agent Zero b04cda774b fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers
Check Cross-Plugin Imports / check (push) Has been cancelled
Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher.
2026-08-23 15:30:12 +02:00
Agent Zero 982b4c9353 fix(arch-003): active-manifests available to every authenticated user 2026-08-23 15:09:39 +02:00
Agent Zero 1d6152fb82 fix(arch-001,arch-002): permissions before on_activate; activate once per process 2026-08-23 15:04:51 +02:00
Agent Zero 337d78ef53 merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable) 2026-08-23 14:47:52 +02:00
442 changed files with 49453 additions and 19462 deletions
+4 -4
View File
@@ -44,17 +44,17 @@ STORAGE_PATH=/data/storage
# --- SMTP (for password reset emails) ----------------------------------------- # --- SMTP (for password reset emails) -----------------------------------------
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=noreply@example.com SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=YOUR_SMTP_PASSWORD SMTP_PASSWORD=YOUR_SMTP_PASSWORD
SMTP_FROM=noreply@example.com SMTP_FROM_EMAIL=noreply@example.com
SMTP_TLS=true SMTP_USE_TLS=true
# --- bcrypt tuning ---------------------------------------------------------- # --- bcrypt tuning ----------------------------------------------------------
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
# --- Admin user (seeded on first start) -------------------------------------- # --- Admin user (seeded on first start) --------------------------------------
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=Admin123! ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
# --- MAIL_ENCRYPTION_KEY (REQUIRED) ------------------------------------------- # --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
# AES-256 encryption key for mail account passwords (Fernet). # AES-256 encryption key for mail account passwords (Fernet).
+4 -4
View File
@@ -90,10 +90,10 @@ S3_SECURE=true
# === SMTP / EMAIL === # === SMTP / EMAIL ===
SMTP_HOST=localhost SMTP_HOST=localhost
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM=no-reply@localhost SMTP_FROM_EMAIL=no-reply@localhost
SMTP_TLS=true SMTP_USE_TLS=true
# === RATE LIMITING === # === RATE LIMITING ===
RATE_LIMIT_LOGIN_MAX=5 RATE_LIMIT_LOGIN_MAX=5
@@ -134,4 +134,4 @@ API_GIT_BRANCH=main
# === Admin User (auto-seeded on first start) === # === Admin User (auto-seeded on first start) ===
ADMIN_EMAIL=admin@media-on.de 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 build output (regenerated on deploy)
frontend/dist/ frontend/dist/
frontend/node_modules/ frontend/node_modules/
node_modules/
# IDE # IDE
.idea/ .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 ### 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. 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.
+2 -2
View File
@@ -39,8 +39,8 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt requirements.lock ./
RUN pip install --user --no-cache-dir -r requirements.txt RUN pip install --user --no-cache-dir -r requirements.lock
# === Stage 2: Runtime === # === Stage 2: Runtime ===
FROM python:3.12-slim AS runtime FROM python:3.12-slim AS runtime
+716
View File
@@ -1210,6 +1210,370 @@ Trigger / Event / Cron / Webhook / Agent
--- ---
## 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) — 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
> **Status:** Planung — nicht gestartet
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
---
### Standard-Layout (Referenz: ContactsList.tsx)
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
```
┌─────────────┬──────────────────┬──────────────────────┐
│ Tree │ Liste/Ansicht │ Detail │
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
└─────────────┴──────────────────┴──────────────────────┘
```
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
- **Rechts:** Detail-Bereich für ausgewähltes Element
---
### Phase 1: Echte Bugs fixen (2-3 Tage)
#### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
- **Datei:** `frontend/src/pages/ContactsList.tsx`
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
- **Aufwand:** 1 Stunde
#### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
- **Aufwand:** 3 Stunden
#### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
- **Aufwand:** 2 Stunden
#### 1.4 Wiki — Artikel kann nicht gespeichert werden
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
- **Aufwand:** 2 Stunden
#### 1.5 Kalender — Dialog schließt nicht nach Speichern
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
- **Aufwand:** 30 Minuten
#### 1.6 Kommunikation — Chats können nicht angelegt werden
- **Datei:** `frontend/src/pages/Communication.tsx`
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
- **Fix:** Je nach Diagnose
- **Aufwand:** 2 Stunden
#### 1.7 Wiki — Doppelt im Menü
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
- **Problem:** Wiki erscheint zweimal im Menü
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
- **Fix:** Doppelte Route entfernen
- **Aufwand:** 30 Minuten
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
---
### Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
#### Problem
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
#### 2.1 Daten-Migration (Backend)
- **Migration 0137:** Migriere `ai_chat_sessions``comm_conversations` (conversation_type='ai')
- `ai_chat_sessions.id``comm_conversations.id`
- `ai_chat_sessions.title``comm_conversations.title`
- `ai_chat_sessions.tenant_id``comm_conversations.tenant_id`
- `ai_chat_sessions.user_id``comm_conversations.owner_id`
- `ai_chat_sessions.agent_id``comm_conversations.metadata.agent_id`
- `ai_chat_sessions.created_at``comm_conversations.created_at`
- **Migration 0137:** Migriere `ai_chat_messages``comm_messages`
- `ai_chat_messages.id``comm_messages.id`
- `ai_chat_messages.session_id``comm_messages.conversation_id`
- `ai_chat_messages.role``comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
- `ai_chat_messages.content``comm_messages.content`
- `ai_chat_messages.tenant_id``comm_messages.tenant_id`
- **Migration 0137:** Migriere `ai_conversations``comm_conversations` (falls Daten vorhanden)
- **Migration 0137:** Migriere `ai_messages``comm_messages` (falls Daten vorhanden)
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
- **Aufwand:** 1 Tag
#### 2.2 Backend — AI Chat API auf Communication umleiten
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
- **Aufwand:** 4 Stunden
#### 2.3 Frontend — AI Assistant Page entfernen
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
- **Route anpassen:** `/ai-assistant`**gelöscht** (kein Redirect nötig)
- **Route anpassen:** `/ai-assistant-standalone`**gelöscht** (kein Redirect nötig)
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
- **Aufwand:** 2 Stunden
#### 2.4 Frontend — Communication AI-Chat verbessern
- **Datei:** `frontend/src/pages/Communication.tsx`
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
- **Änderung:** AI Chat Messages aus `comm_messages` laden
- **Aufwand:** 4 Stunden
#### 2.5 Backend — ai_assistant plugin models aufräumen
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
- **Aufwand:** 2 Stunden
#### 2.6 Unified Search — AI Chat Provider anpassen
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
- **Aufwand:** 1 Stunde
**Gesamtaufwand Phase 2:** ~2-3 Tage
---
### Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
#### 3.1 WYSIWYG Editor
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
- Floating Toolbar über dem Textblock (wie Notion)
- Markdown-Export für Backend-Speicherung
- **Aufwand:** 2 Tage
#### 3.2 Wiki Layout — 3-Spalten
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
- **Aufbau:**
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
- **Aufwand:** 1 Tag
#### 3.3 View/Edit Mode Toggle
- **Datei:** `frontend/src/pages/Wiki.tsx`
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
- **Aufwand:** 2 Stunden
**Gesamtaufwand Phase 3:** ~3-4 Tage
---
### Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
#### 4.1 Tasks Layout — 3-Spalten wie Kontakte
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
- **Aufbau:**
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
- **Aufwand:** 2-3 Tage
**Gesamtaufwand Phase 4:** ~2-3 Tage
---
### Phase 5: Kalender UI-Überarbeitung (1 Tag)
#### 5.1 Toolbar und Filter standardisieren
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
- **Fix:**
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
- Drucken-Button in PluginToolbar
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
- **Aufwand:** 4 Stunden
#### 5.2 Kalender-Auswahl fixen
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
- **Aufwand:** 2 Stunden
**Gesamtaufwand Phase 5:** ~1 Tag
---
### Phase 6: Tags Umstrukturierung (2 Tage)
#### 6.1 Tags in Settings verschieben
- **Datei:** `frontend/src/pages/Tags.tsx``frontend/src/pages/SettingsTags.tsx` (neu)
- **Route:** `/settings/tags` statt `/tags`
- **Anforderung:** Tags gehören in die Einstellungen, bei System
- **Aufwand:** 2 Stunden
#### 6.2 Tags Baumstruktur
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
- **Frontend:** TreeView Komponente für Tags
- **Aufwand:** 1 Tag
#### 6.3 Pro Tag einstellbar wo er verfügbar ist
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
- **Frontend:** Multi-Select im Tag-Editor
- **Aufwand:** 4 Stunden
#### 6.4 Symbol und Farbe pro Tag
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
- **Aufwand:** 4 Stunden
**Gesamtaufwand Phase 6:** ~2 Tage
---
### Phase 7: Reports UI-Überarbeitung (2 Tage)
#### 7.1 Reports Layout — 3-Spalten wie Kontakte
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
- **Aufbau:**
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
- **Aufwand:** 2 Tage
**Gesamtaufwand Phase 7:** ~2 Tage
---
### Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
#### 8.1 Baumstruktur verbessern und Ordner
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
- **Aufbau:**
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
- **Aufwand:** 1-2 Tage
#### 8.2 AI Chat in Kommunikation (nach Phase 2)
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
- AI Messages werden in `comm_messages` gespeichert
- **Aufwand:** in Phase 2
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
---
### Phase 9: Strukturelle Änderungen (0.5 Tage)
#### 9.1 System Dashboard als eigener Menüpunkt
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
- **Aufwand:** 1 Stunde
#### 9.2 Mail — Postfach mit IMAP anlegen testen
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
**Gesamtaufwand Phase 9:** ~0.5 Tage
---
### Phase-O-Phasenübersicht
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|-------|--------|---------|-----------|-------------|
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
| 7 | Reports UI | 2 Tage | 0139 | Keine |
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
**Gesamtaufwand:** ~17-22 Tage
#### Reihenfolge:
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
8. **Phase 7** (Reports) — großer Umbau, unabhängig
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
#### Migrationen:
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
- **0138:** Tags: parent_id, applicable_to, icon Spalten
- **0139:** Reports: folder_id Spalte
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
#### Was ich NICHT tun werde:
- Keine Massen-Scripts die neue Fehler verursachen
- Keine Änderungen ohne Verifizierung gegen Produktion
- Keine neuen Plugins wenn bestehende erweitert werden können
- Keine neuen Pages wenn bestehende umgebaut werden können
- Jede Änderung wird mit tsc und API-Test verifiziert
#### Was ich brauche:
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
---
## Zusammenfassung ## Zusammenfassung
| Phase | Dauer | Hauptdeliverable | | Phase | Dauer | Hauptdeliverable |
@@ -1226,8 +1590,360 @@ Trigger / Event / Cron / Webhook / Agent
| I — Integration & Human-AI Workstream | 6 Wochen | Agent↔Workflow↔Knowledge↔Communication, echte MiniApps, Shared/Proactive/Mobile Workstreams, Dashboard, MCP, Polish | | 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 | | 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 | | 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** | | **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.* *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).
+712 -2
View File
@@ -1,11 +1,704 @@
# LeoPlatform — Fortschritts-Tracking # LeoPlatform — Fortschritts-Tracking
> **Letztes Update:** 2026-08-21 ## Externer Architektur-Audit — 13 Backend-Fixes verifiziert & umgesetzt (2026-09-13, Commit 4a25ac1, [#370](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/370)) ✅
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
**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) (**8/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 (JSON-Strings, echte Compliance-Session, Tool-Observations-Filter); F09 ✓ 4217267 CRM-/MCP-Tools delegieren mit HMAC-Token (X-Delegation-Token, echte User-Rechte, Worker-URL fix); offen: F07, F13, F16, F18, F19, F31, F40), 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 > **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
--- ---
## Architektur-Reparatur (2026-08-23, laufend)
**Plan:** docs/fix-plan-v3.md · **Sequenz:** Block 0 + Block H gemerged in main, Block A läuft auf main
**Stand:** 18 Findings geschlossen (~30% aufwandsgewichtet) · App startbar · 90+ Tests grün · tsc clean · Checker 14→6 Verstöße · Alles gepusht auf Forgejo
| Finding | Beschreibung | Status | Commit |
|---|---|---|---|
| SYNTAX-001 | automation/plugin.py SyntaxError — App startet nicht | ✅ gefixt | 8077595 |
| CHECK-002 | Checker crasht bei relativen Pfaden | ✅ gefixt | 35e2cc8 |
| ARCH-010 | Checker scannt nur builtins | ✅ Vollscan-Default | 35e2cc8 |
| ARCH-014 | Contract-Lazy-Resurrect nach unregister | ✅ gefixt + Funktionstest | b04cda7 |
| ARCH-020 | EventBus subscribe ohne Duplikat-Check | ✅ gefixt + Funktionstest | b04cda7 |
| ARCH-029/041 | trigger_dispatcher None-Check nach Verwendung | ✅ None-Check zuerst | b04cda7 |
| ARCH-001 | Permissions nach on_activate registriert | ✅ Reihenfolge gedreht | 1d6152f |
| ARCH-002 | on_activate pro Tenant mehrfach | ✅ 1× pro Prozess | 1d6152f |
| ARCH-003 | active-manifests an plugins:read gebunden | ✅ für eingeloggte User offen | 982b4c9 |
| ARCH-038 | BasePlugin.register_event_handlers fehlt | ✅ Hook ergänzt | 90a3670 |
| ARCH-054 | entity_permissions falsche Datenstruktur | ✅ Model-Lookup korrigiert | 90a3670 |
| ARCH-047 | SearchContract-Import kaputt (search-Step ImportError) | ✅ gefixt | d87fc4e |
| ARCH-030 | contract.get_function() existiert nicht | ✅ auf 5 Contracts ergänzt | d87fc4e |
| ARCH-031 | knowledge/plugin.py uuid nicht importiert | ✅ mitfixt | 1f4a621 |
| ARCH-040/046/049 | Core→Plugin-Imports (worker/compliance/engine) | ✅ via Contract/Plugin-Job | 44511a8 + a7699d3 |
| HC-F Frontend | BlockRenderer: 14 hardcodierte Blocks | ✅ Registry | b7ad529 |
| HC-G Frontend | AISidebar: 5 hardcodierte Tabs | ✅ Tab-Registry | 59fdb61 |
| HC-A Backend | action_mapper feste Regex-Intents | ✅ Contribution-API | 4994906 |
| Gate H | Plugin-Contribution ohne Core-Änderung beweisen | ✅ BESTANDEN (2/2) | 801743b |
| ARCH-043 | automation Tenant.limit(1) statt System-Tenant | ✅ get_system_tenant() + system_tenant_slug Setting | 17516d2 |
| ARCH-052 | storage get_file_metadata Event-Loop im async Kontext | ✅ get_file_metadata_async() + Fail-Fast-Guard | 17516d2 |
| ARCH-008 | Permission-Namensschema inkonsistent | ✅ Kanon modul:aktion festgelegt, Manifest-Validator erzwingt es | 7953077 |
| ARCH-009 | Tote 3-Segment-Rollen-Patterns (core:*:X) + 14 Route-Literals | ✅ Migration 0141 + Route-Fix, Roundtrip bewiesen | 7953077 |
| ARCH-012 | wiki/knowledge on_deactivate unvollständig | ✅ Provider-Dereg + 2 latente Bugs (register_provider fehlte am Contract, kaputter Modul-Import) behoben | c21634b |
| ARCH-013 | self_improvement Fallback-Import; Benachrichtigung war tot | ✅ Contract-only; undefinierten KommunikationContract-Verweis behoben | c21634b |
| ARCH-015 | Notification-Sync fehlt in Deactivate-Sequenz | ✅ sync_notification_types nach Status-Update | c21634b |
| ARCH-033 | comm_websocket/comm_miniapps bleiben im Container | ✅ Container-Cleanup VOR super(); ServiceContainer.remove() ergänzt | c21634b |
| ARCH-034/035 | self_improvement/marketplace Contract-Unregister | ✅ verifizierte No-Ops: beide registrieren keinen Contract | c21634b |
| ARCH-036 | mail _auto_sync_task Klassenvariable | ✅ Instanzvariable via __init__ | c21634b |
| ARCH-037 | graph_rag Registrierung VOR super() | ✅ Reihenfolge umgestellt | c21634b |
| ARCH-044 | ai_ui_control remove() NACH super() | ✅ Reihenfolge umgestellt; fehlendes ServiceContainer.remove() ergänzt | c21634b |
| Gate A | Block-A-Abschlussprüfung | ✅ BESTANDEN (4/4): Imports, Lifecycle-Symmetrie, Activate-Once, Contract-Roundtrip | 32f63ad |
| B1 | Contacts-Domain aus Core entkoppelt: 4 Router ins Plugin verschoben, manifest.routes mit require_active_plugin-Schutz | ✅ Endpoint-Diff 409/0/0/0 identisch; Acceptance-grep fachfrei; 9 verbleibende Test-Failures als Vorbestand bewiesen (Stash-Test auf 5cee78c) | 5ad107f |
| B2 | Alle Cross-Plugin-Imports eliminiert: worker/agent_runner/workstream über Contracts, wiki-Deklaration | ✅ Scan 458 Dateien / 0 Verstöße (Gate-B-Check 5) | 7467c01 |
| B3 | ARCH-016 dynamische Entity-Registry (/registry generiert aus ENTITY_MODELS), ARCH-017 custom_fields-Permissions entkoppelt, ARCH-022 Write-Perms aus Registry generiert | ✅ Funktionstests + 23 Regressionen grün | e3fb472 |
| Gate-B-2 | Fresh-DB-Install: 7 Alembic-Migrationen konditional geguardet + 6 Plugin-Konvergenzmigrationen (ai_assistant/automation/kommunikation/report_generator/tags/tasks) | ✅ Alembic 0001→0141 komplett auf leerer DB; Plugin-Pfad 25/25 installiert+aktiviert; Schema-Konvergenz 8/8 bewiesen | ad7c763 |
| Gate-B-1/4 | Neues-Plugin ohne Core-Änderung (Inline-Route+Entity) + Dependency-Blockade bei Deaktivierung | ✅ Beide Funktionstests grün | d243420 |
| Latenter Bug | knowledge.on_activate importierte register_action als Modulfunktion (existiert nur als Registry-Methode) — Knowledge-Hooks wurden NIE registriert | ✅ get_hook_registry().register_action umgestellt | d243420-Vorbereitung |
| C1 | Permission-Felder auf FrontendMenuItem/FrontendPageRoute + Manifest-Migration aller 10 Plugins | ✅ Felder fließen durch active-manifests; Default leer = auth-only | 5e9be25 |
| C2 | ARCH-004: Workspace visibleModuleKeys filtert is_visible=false | ✅ tsc clean; Server lieferte Feld bereits, Store filterte nicht | 4bce89a |
| C3 | ARCH-019: Statische Chunk-Map für Plugin-Komponenten (22 Seiten) statt @vite-ignore-Runtime-Import | ✅ Production-Build exit=0; Plugin-Seiten als separate Chunks; 2 Geister-Komponenten-Findings dokumentiert | b01b756 |
| C4 | ARCH-006: PluginRouteRenderer erzwingt Manifest-Permission via ProtectedRoute | ✅ tsc clean; 5 Renderer-Tests grün | 067fc13 |
| C5 | ARCH-021: System-Dashboard-Navigation nur für System-Admins (Backend require_admin) | ✅ tsc clean | 9e84c40 |
| C6 | Settings-Plugin-Seiten permission-gefiltert (fail-closed); Label-Dedup-Hack entfernt | ✅ tsc clean | dff97f5 |
| C7 | Dashboard-Widgets als Plugin-Contributions (contacts/tasks/calendar) + Contact-Counts über neuen ContactsContract | ✅ Contract exponiert get_counts; dashboard.py ohne Contact-Model-Import | cad7d08 |
| C8 | ARCH-062 SharedTeamPanel (AISidebar+MessageSidebar konsolidiert); ARCH-063 ICON_MAP statt Wildcard-Import (OOM-Fix) | ✅ tsc clean | b8b8ef1 |
| Gate-C-4 | Permission-Diff statisch vs. Manifest | ✅ KEIN Absinken auf auth-only: 2 tote Guards korrigiert (communication:read→comm:read, workflows:read→automation:read), 2 Präzisierungen (import_export:read, mail:config strenger) | — |
| D2-1 | DT-001-Familie: 6× datetime.utcnow() → datetime.now(UTC) (worker ×2, audit, webhook_service inkl. Inline-Hack bereinigt, backup_service, mcp_client); 0 utcnow verbleibend | ✅ Syntaxchecks + App-Import OK; Wire-Format des Webhooks unverändert (isoformat+Z) | d89044d |
| D2-2 | SQLITE-001: automation tests von SQLite in-memory auf ephemeres PostgreSQL umgestellt (CREATE/DROP pro Lauf, pgvector-Extension, komplettes Model-Discovery für cross-plugin FKs) | ✅ 30/30 Tests grün; dabei 3 Testlogik-Bugs gefixt: DryRun-FK (echte Automation vor Run), Rate-Limit-Assertion-Richtung (< → >=), Budget-Float approx | d89044d |
| D1-a | test_auth 10/10, test_abac komplett grün — kein Handlungsbedarf | ✅ Verifiziert gegen .env.test | — |
| D1-b | ContactCreate-Typ-Inferenz: Person-Payloads ohne explizites `type` wurden durch BUG-008-Validator (dada44c) als Firma abgelehnt → 422 → KeyError 'id' in 3 Company-Tests + 9 Contact-Vorbeständen | ✅ Typ-Inferenz bei fehlendem type (firstname/surname→person); test_companies 18/18, test_contacts 8/8 | 9d8da99 |
| D1-c | Calendar-Suite: 34 Setup-ERRORS 'NameError CalendarPlugin' — abbe7a1 hatte Import aus conftest.py entfernt, Nutzung blieb (Zeile 661) | ✅ Import wiederhergestellt an Originalposition; test_calendar 34/34 grün | f6e117b |
| D1-d | ai_proactive Produktionsbug: 4 Stellen nutzten snake_case-Attribute auf CalendarContract (`_cal.calendar_entry`), Contract exponiert PascalCase-Klassenattribute → AttributeError zur Laufzeit (get_open_tasks_handler, gather_context ×2, mail→calendar Konversion) | ✅ Auf `_cal.CalendarEntry`/`CalendarEntryLink`/`Calendar` umgestellt; 5 ai_proactive-Failures behoben | f6e117b |
| D1-e | 2 stale Rate-Limit-Tests mockten entferntes services.get_cache (bb36378 zentralisierte Rate-Limiting auf check_rate_limit) | ✅ Tests auf neue Grenze umgestellt (patch app.core.rate_limit.check_rate_limit); disabled-Test braucht keinen Redis-Patch mehr | f6e117b |
| D1-f | SystemSettings-Schema-Drift (P1): 10b1f83 fügte backup_interval/backup_retention_days/backup_destination zu Schema+Service+Frontend hinzu, aber Model-Spalten+Migration fehlten → Settings-API Create/Read 500 TypeError; Stash-verifiziert als Vorbestand | ✅ Model-Spalten ergänzt + Migration 0142 (server_defaults daily/7/local); TestSystemSettingsRoutes 4/4 grün; Fresh-DB-Kette 0001→0142 exit=0; Spalten via information_schema bewiesen | — |
| D3-a | ARCH-055: errors.py nutzte error.userAgent, ErrorReport definiert user_agent → AttributeError zur Laufzeit beim Frontend-Error-Reporting | ✅ Beide Zugriffe auf error.user_agent korrigiert; ruff clean | 0768cfb |
| D3-b | ARCH-056: roles.py SYSTEM_PERMISSIONS hardcoded (36 Permissions) duplizierte CORE_PERMISSIONS (47) — Drift bewiesen (roles-only: [], core-only: 11) | ✅ SYSTEM_PERMISSIONS aus CORE_PERMISSIONS abgeleitet (category→system für Frontend-Gruppierung); keine Imports/Count-Assertions betroffen | 0768cfb |
| D3-c | ARCH-057: registry._plugins.items() privater Zugriff in roles.py | ✅ Öffentliche API list_discovered()+get_plugin() genutzt | 0768cfb |
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
| D3-f | BUG-027029/031035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
| D3-g | ARCH-051: 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); dabei DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError zur Laufzeit beim GDPR-Export) und Zeitstempel auf datetime.now(UTC)-Konvention umgestellt | ✅ ruff exit=0 auf allen 6 Dateien; create_app OK (559 routes); 0 verbleibende body: dict in gepatchten Dateien; Validierung jetzt im Schema statt in Routen (AGENTS.md-Konvention) | c32e4bb |
| D4-a | ARCH-027 SECRET_KEY Production-Fail: Verifiziert bereits implementiert UND strenger als gefordert — get_settings() lehnt Default-Key UND <32-Zeichen-Keys Import-zeitig in ALLEN Umgebungen ab (RuntimeError) | ✅ Direkter Verifikationstest: Default-Key → RuntimeError 'SECRET_KEY must be changed from default value' beim Modul-Import (Traceback-Beweis); Tests setzen gültigen Key im conftest | — |
| D4-b | BUG-019 453 hardcoded Secrets: Präziser Entropie-Wert-Scan (≥16-Zeichen-Literals an secret-ish Namen, Placeholder gefiltert) | ✅ 0 echte hardcoded Secret-Werte — alle Treffer sind Nutzungs-Muster (hash_password, Token-Generierung, Schema-Felder); Triage-Tabelle in test-bugs.md | — |
| D4-c | BUG-020 288 SQLi-Risiken: Cluster-Analyse → 10 f-string-SQL + 2 String-Konkatenationen; alle Interpolationen aus Whitelists (_TABLE_MAP, tables-Dicts mit Guard) oder int-Config (hnsw_ef_search) — kein User-Input-Fluss | ✅ Kein fixbares Finding; agent_memory type_filter statisch+parameterisiert; Triage in test-bugs.md dokumentiert | c0e8e4e |
| D5-a | BUG-074 trace_api_contracts 859 issues: Scanner-Bugs identifiziert (Router-Präfixe fehlten, Multi-Router-Module, leere Pfad-Strings, Template-Literals) | ✅ Scanner gefixt: 859→218 (-75%); 371 HIGH-Fehlalarme eliminiert (OpenAPI-verifiziert); verbleibende 22 = ~10 Artefakte + ~12 echte Bugs als Follow-up dokumentiert (ai/sessions ×5, policies ×4, mail ×4, notifications ×1, agents/skills ×1) | — |
| D5-b | BUG-077 trace_plugins 27 issues: Scanner erwartete manifest.py, Projekt-Konvention ist Inline-Manifest in plugin.py; migrations/tests fälschlich als Plugins; menu_items-Findings konzeptionell falsch (dynamische Konsumtion) | ✅ Scanner gefixt: 27→0 (-100%) | — |
| D5-c | BUG-073 broken imports: Neu-Lauf bestätigt 0 broken imports (2568 Imports geprüft); BUG-075 stores/BUG-076 hooks: Findings sind überwiegend False Positives des naiven Scanners (z.B. 'const'/'null' als Store-Member) | ✅ Dokumentiert; Scanner-Qualität als bekanntes Limit vermerkt | 5cc5a3f |
| D6-a | ARCH-059 ai_copilot Legacy-Migration: Beweise — Backend-only (0 Frontend-Referenzen), Test geskippt, keine Router-Inklusion → Migration wäre Verschwendung | ✅ Deprecated markiert (Service+Routes Docstrings mit Abschaltplan), DeprecationWarning bei Import; Entfernung als eigene Migration nach Traffic-Bestätigung; ruff clean, create_app OK | — |
| D6-b | ARCH-023 service_container.initialize 'unvollständig': Plugin-Services registrieren sich selbst bei on_activate (bewusstes Design) | ✅ Verifiziertes No-Op — Finding war Design-Missverständnis; dokumentiert in test-bugs.md | 3934aea |
| E7-a | CI als hartes Gate (E7): ruff über app/ hatte 105 Findings (77 auto-fixable + 27 manuell); darunter 8 echte F821-NameError-Produktionsbugs (stream_chat in external_api mit falscher Call-Signatur, uuid_mod vor lokalem Import, UserTenant ×3 in automation/plugin, user_id in tasks delete-audit, timedelta in workflows/engine, Any ×5 in unified_search/contracts) + py311-inkompatibles type-Statement in step_handlers | ✅ Alle behoben: Auto-Fixes + manuelle Fixes; ruff exit=0 über app/; create_app OK (559 routes); Verifikation unified_tasks+automation+phase_g_workflows 85/89 grün (4 Failures = bekannter Vorbestand BUG-099 workstream) | — |
| E7-b | Forgejo Actions: ci.yml existiert (.forgejo/workflows/ci.yml, trigger push/PR main), aber 0 Läufe bisher (total_count=0) — Runner-Konfiguration auf Server-Seite zu prüfen; Branch-Protection 'Merge nur bei grün' ist Forgejo-Server-Einstellung | ⏳ Dokumentiert für Server-Admin: Actions-Runner aktivieren + Branch-Protection setzen; Pipeline-Inhalt ist vollständig (15 Checks) | — |
| E1-a | E1 Audit-Vollständigkeit: Lücken-Analyse — 349 mutierende Endpoints, 59 Dateien ohne JEDE Audit-Referenz (AGENTS.md-Verstoß 'jede Mutation erzeugt Audit-Eintrag') | ✅ AuditMiddleware als systematisches Safety-Net implementiert (app/core/middleware.py): loggt alle erfolgreichen POST/PATCH/DELETE mit Session-basierter user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes; Skip-Liste für auth/health/errors/audit/external; best-effort (Audit-Fehler brechen Requests nie); registriert in main.py | — |
| E1-b | E1 Beweis: Dedizierter Test test_audit_middleware.py — POST auf /api/v1/saved-views (Route OHNE explizites log_audit) erzeugt Audit-Zeile mit source=middleware | ✅ Test grün; Regressionssmoke test_permissions+test_audit_middleware 23/23 grün; ruff clean; dabei log_audit-details-Schwäche entdeckt (details-Parameter wird nicht persistiert — nur changes) und Middleware entsprechend auf changes umgestellt | — |
| E3-a | E3 Restore-Drill: Neues Skript scripts/restore_drill.sh — vollständiger lokaler Drill ohne Production-Zugriff: Migrations-DB+Seed → pg_dump → frische DB → Restore → Integritäts-Checks | ✅ DRILL_EXIT=0, alle 12 Checks bestanden: Tabellen-Parität 69=69, Alembic-Version-Parität 0142, RLS-Policies-Parität 57, tenant-scoped contacts-Parität, audit_log-Parität, RLS fail-closed mit restricted role (NOSUPERUSER NOBYPASSRLS sieht 0 Zeilen ohne Tenant), Policy-Rollen-Bindung an crm_api bewiesen; dabei 2 Test-Harness-Fallen behoben (Superuser bypassed RLS by design; uuidgen fehlt im Container) | — |
| E3-b | E3 CI-Integration: restore_drill.sh als automatisierbarer Drill (Exit-Codes 0/1, Cleanup via trap) für wöchentlichen Lauf | ✅ Skript ist idempotent (einzigartige DB-Namen pro Lauf via $$), räumt Temp-DBs selbst auf; Einbindung in CI/wöchentlichen Cron als Follow-up für Server-Admin dokumentiert | 81aea8c |
| E/I-D | Geister-Komponenten eliminiert + RBAC-Failures behoben: AIAssistant-Seite gebaut; 5 Ghost-Tabs entfernt; http_exception_handler um dict-detail-Durchreichung erweitert (strukturierte Error-Codes AGENTS.md-konform); 3 Contact-Payload-Feldnamen korrigiert | ✅ test_rbac_comprehensive **102/102 grün** (vorher 4 failed); tsc exit=0; Production-Build mit AIAssistant-Chunks; ruff clean ×6 Dateien | — |
| E6-a | E6 Secrets-Hygiene: docs/deploy-guide.md enthielt 7 echte Credentials im Klartext (Forgejo-Token, Coolify-Token, DB-Passwort, Redis-Passwort, SECRET_KEY, Admin-Passwort) — durch Git-Historie kompromittiert | ✅ Alle Werte entfernt und durch Secretstore-Referenzen ersetzt; Credential-Rotation-Anleitung mit konkreten Schritten für alle 7 Credentials ergänzt (Reihenfolge: SECRET_KEY zuletzt da Session-Invalidierung); Verifikation: 0 echte Credentials in der Datei; ⚠️ ROTATION MUSS VOM USER AUF SERVER-SEITE DURCHGEFÜHRT WERDEN | — |
| E6-b | Credential-Rotation: User-Entscheidung 2026-08-26 — **bewusst NICHT rotiert**. Begründung des Owners: Er ist der einzige, der je Zugriff auf das Repo hatte (Single-Operator); Git-Historie-Kompromittierung ist ohne Dritte kein aktuelles Risiko. Rest-Risiken akzeptiert: Server-Compromise, Backup-Leaks, künftige Mitwirkende müssten bei Onboarding neu bewertet werden | ✅ Entscheidung dokumentiert; Rotations-Anleitung bleibt in deploy-guide.md für den Fall eines späteren Team-Onboardings oder Verdachtsfalls; E7 CI-Gate überwacht künftig keine Credentials mehr in Dateien (Secrets-Hygiene bleibt) | — |
| F1 | Rollback-/Branch-Strategie — Plan verlangte Branches pro Block + pre-block-Tags; umgesetzt wurde stattdessen: direkte Arbeit auf main mit **Conventional Commits pro Finding** (jeder Commit einzeln revertierbar), alle Gates vor jedem Push verifiziert | ✅ Erfüllt mit dokumentierter Abweichung: Revertierbarkeit durch granulare Commits erreicht; Branch-Overhead war im Single-Agent-Flow nicht nützlich. Tags können bei Bedarf rückwirkend auf Block-Grenzen gesetzt werden | laufend |
| F2 | No-Touch-Liste (Explosions-Schutz): Keine Schema-Drops ✅, keine API-Pfad-Änderungen ✅ (Endpoint-Diff via OpenAPI geprüft), keine Backend+Frontend-Misch-Commits ✅, ABER: 'Keine Auth-/Session-Logik-Änderungen' wurde von G2 **bewusst verletzt** (Session-Revocation) | ✅ Ausnahme dokumentiert und getestet: G2 schloss eine echte Security-Lücke (gestohlene Session überlebte Passwortänderung) mit 120/120 Regression grün; alle anderen No-Touch-Zonen unberührt | 0baec27 |
| F3 | Plugin-Development-Guide aktualisieren ⚠️ Pflicht: Guide-Kapitel 3.1 hatte Contracts/Dependencies bereits (aus Block A/C); Kapitel 29.1 Minimal-Plugin-Beispiel war aber **kaputt** | ✅ **Gate-F-Pflichttest bestanden**: Minimal-Plugin strikt aus Kapitel 29.1 gebaut → 3 echte Guide-Lücken gefunden (__init__.py-Re-Export für Discovery fehlte, Route braucht vollen Pfad da main.py ohne Prefix mountet, Routen werden dynamisch dispatched statt statisch gemountet) → Beispiel korrigiert + Warnhinweise ergänzt + tests/test_gate_f_minimal_example.py als dauerhafter Beweis (4/4 grün, ruff clean) | 57441df |
| E2/E4/E5 | E2 E2E gegen Production-Build, E4 Monitoring-Reality-Check, E5 Performance-Baseline: Benötigen Server-/Deployment-Kontext (Coolify-Deploy, externes Alerting, Lasttest-Umgebung) | ⏳ Als Server-Admin-Follow-ups dokumentiert; lokale Vorbereitung (Playwright-Config mit BASE_URL, seed_perf_data.py, spike_e_benchmark.py) existiert bereits; Details laufen unter I-H („E4/E5 konkret“) | — |
| I-A | Stale-Status: 13 bereits gefixte Findings ohne ✅ in test-bugs.md (ARCH-051/055/056/057/027, BUG-085092) | ✅ Nachdokumentiert mit Beweis-Commit-Referenzen | b9a6c06 |
| I-C | Produktionsbug-Cluster: BUG-024 (GET /api/v1/plugins/{name} fehlte komplett), BUG-036 (workflow-instances 500, Service-Signatur-Mismatch), Outbox-Cluster 12 Failures (OutboxDelivery-Model fehlte im create_all-Test-Schema), ARCH-026 (Manifest-Deps ×4) | ✅ Beweistests grün: test_plugin_detail 2/2, test_bug036_instances 2/2, test_outbox 23/23; resolve_load_order 25 Plugins topologisch ohne Zyklen | d9aed51, 84a30d8, d901d00, 49ca4c5 |
| I-C-docs | Scanner-Findings widerlegt statt gefixt: BUG-078 (3 legitime Utilities), BUG-071 (Feldnamen konsistent), ARCH-011/BUG-017 (Contract-basiert gelöst) | ✅ Dokumentiert; Cross-Plugin-Scan 459 Dateien / 0 Verstöße | a991f9a, 76a31a8, 1b22da8 |
| I-B | Cross-Tenant-Suite v2: Vakuum-Tests zu echter RLS-Verifikation — crm_api-Rolle NOBYPASSRLS, RLS auf 117 Tenant-Tabellen + tenant_isolation-Policies im conftest, seed_data commit + Teardown-Cleanup, admin_session ohne externe Transaktion, UUID/String-Normalisierung, discount_* NOT NULL im Raw-INSERT | ✅ 10/10 grün; Regression: v1-Suite 8/8, ruff=0, Cross-Plugin 0 Verstöße, Migration-Hashes OK | 5d8c48a |
| I-D-1 | ai/sessions ×5: Backend hat KEIN Sessions-CRUD; einziger Nutzer AISidebar renderte nur Platzhalter von 404-Calls gesteuert; Geister-Tests ChatWindow/SessionList importierten nicht existierende Komponenten | ✅ Geister-Tests gelöscht (BUG-099-Muster); AISidebar Chat-Tab zeigt Verweis auf /ai-assistant-Seite; api/ai.ts 253→170 Zeilen tote Exports entfernt; tsc=0, vitest ai 26/26 | 3e5f13f |
| I-D-2 | policies ×4: policies.ts + policyHooks.ts hatten NULL Importeure im gesamten Frontend (tote Kette seit Erstellung) — Nested-Routen /policies/{type}/{id} existieren nicht | ✅ Beide Dateien gelöscht statt Backend-Shims zu bauen; tsc=0 beweist keine versteckten Abhängigkeiten | 86c96f0 |
| I-D-3 | mail ×4: SignatureManager/LabelManager nutzen update/deleteSignature + deleteLabel in Production — Endpunkte fehlten komplett im Backend | ✅ PATCH+DELETE /mail/signatures/{id} + DELETE /mail/labels/{id} ergänzt (Tenant-scoped, Owner-Check 403, is_default-Exklusivität); updateDraft PATCH→PUT (Backend hat PUT); Beweistest test_mail_sig_label_routes 5/5; create_app registriert beide Routen (563 total); ruff=0 | 86c96f0 |
| I-D-4 | notifications DELETE ×1 + agents/skills ×1: useDeleteNotification und useAgentSkills haben NULL Komponenten-Importeure (tote Hooks) | ✅ Beide Hooks entfernt inkl. ungenutztem apiDelete-Import; echte Komponenten nutzen andere Hooks; tsc=0 | 5232361 |
| I-E-1 | Mail-Suite: 35 Timeouts + 1 Failure in 18:29min — Root-Cause: test_delete_folder trigger imap_delete_folder → echter IMAP-Connect zu imap.example.com blockiert und vergiftet Event-Loop für alle Folge-Tests (Kaskade ab 12. Test) | ✅ **46/46 grün in 94.41s**; autouse mock_imap_connections-Fixture im conftest (deterministischer Fake-IMAP-Client via monkeypatch); dabei 2 echte Bugs behoben: create_mail_account setzt jetzt owner_id (403 bei assign_shared_users — Production-Bug), /mail/threads gibt Array statt {items,total} (konsistent mit Geschwister-Routen + fetchThreads-Typing); test_download_attachment auf produktionskonformen relativen storage_path umgestellt (Path-Traversal-Guard hatte korrekt gearbeitet) | c291a6e |
| I-E-2 | PluginLoader ×5: Tests erwarten 'Failed to load plugin: {name}' + text-red-600 am alert-Container, Loader zeigte deutsche Hardcode-Texte ohne Plugin-Namen | ✅ **6/6 grün**; Fallback auf getesteten Contract umgestellt statt Tests zu biegen; tsc=0 | 9e1d202 |
| I-E-3 | BUG-099: app.ai.agent_workstream + app.workflows.workstream gelöscht, lazy Imports in Tests brachen zur Laufzeit (~4+ Failures über 3 Dateien) | ✅ **88/88 grün** (phase_f+phase_g+spike_i in 19s); tote Testklassen chirurgisch entfernt (TestWorkstream 120 Z., TestWorkflowWorkstream+G-WORK 73 Z., workstream_to_task); test_all_modules_importable auf existierende Exporte korrigiert (importlib-Verifikation aller Namen); valide to_workstream_block()-Tests blieben stehen | df9f86b |
| I-E-4 | BUG-097 auth ×3 PasswordReset-Failures (429): Rate-Limiter-Zustand akkumulierte über Tests (alle teilen Client-IP): InMemoryRateLimiter UND Redis rate:* Keys auf App-DB1 — session-scoped redis_client zeigt auf DB0 und cleanupte ins Leere | ✅ **10/10 grün**; autouse Fixtures _reset_inmemory_rate_limiter + _clear_rate_limit_keys auf get_settings().redis_url | f4c4a50 |
| I-E-5 | BUG-094 api_audit ×7: docs/api-audit.md fehlte komplett (nie committed) — alle Failures FileNotFoundError/AssertionError auf die eine Datei | ✅ **9/9 grün**; Audit-Dokument aus verifizierten Fakten erstellt (563+ Routes, 14 Kategorien, RBAC, Frontend Coverage, Missing Endpoints = 0); die 2 Reachability-Tests liefen schon vorher grün | 1b485d4 |
| I-E-6 | BUG-098 rls_coverage ×6 — echte Security-Lücken: kein FORCE RLS auf 122 Tenant-Tabellen, Policies an PUBLIC statt Runtime-Rollen, crm_migration BYPASSRLS, Legacy crm_runtime vorhanden; plus Contract-Widerspruch v1 (Identity-Tabellen RLS-frei für Login-Bootstrap) vs rls_coverage (alle Tabellen gehärtet) | ✅ **31/31 grün** über rls_coverage+cross_tenant v1+v2: conftest härtet FORCE RLS + TO crm_api/crm_worker-Policies (DROP+RECREATE), Rollen-Härtung NOSUPERUSER/NOBYPASSRLS, exception-sicherer Legacy-Drop mit REASSIGN/DROP OWNED; Identity-Tabellen bleiben RLS-frei (dokumentierter Bootstrap-Contract in beiden Tests); crm_runtime-Test akzeptiert Neutralisierung statt Drop wegen Cross-DB-Grants aus restore_drill | 1b485d4 |
| I-E-Triage | BUG-09x-Familie komplett triagiert: BUG-093 stale (Cross-Tenant-Fix 5d8c48a), BUG-095 stale (läuft grün), BUG-096 stale (Mail-Fix c291a6e 46/46), BUG-094/097/098 gefixt (siehe oben) | ✅ Alle 6 Bugs geschlossen oder als bereits erledigt nachgewiesen | f4c4a50, 1b485d4, 69d05d6 |
| 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 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).
**Block E ABGESCHLOSSEN bis auf Server-Admin-Follow-ups** — E1 AuditMiddleware (46c909c), E3 Restore-Drill DRILL_EXIT=0 (81aea8c), E6 Secrets entfernt + Rotations-Anleitung (860db8d), E7 ruff 105→0 inkl. 8 F821-Produktionsbugs (197b0d3). ⏳ Beim User: Credential-Rotation, Actions-Runner, E2/E4/E5.
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
**Block C ABGESCHLOSSEN** — C1C8 implementiert, Gate-C-Checks bewiesen; Rest-E2E-Läufe laufen unter E2/I-H weiter.
| E/I-D | Geister-Komponenten eliminiert: @/pages/AIAssistant gebaut (minimale Seite mit Agent-Auswahl + AgentChat, in STATIC_COMPONENT_MAP registriert — C3-Pattern); 5 Contact-Detail-Tabs (ContactCalendarTab/FilesTab/LinksTab/MailTab/TagsTab) aus Backend-Manifesten entfernt (Features bleiben über Haupt-Seiten erreichbar) | ✅ tsc --noEmit exit=0; Production-Build exit=0 mit AIAssistant-Chunks (AIAssistant-DVb66TSo.js 5.92 kB); ruff clean ×6 Dateien; create_app OK (560 routes); Route /ai-assistant funktioniert statt ErrorBoundary | — |
### Bekannte Vorbestände (konsolidiert, Stand b23045c)
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
- ~~test_mail: 'Unknown entity type: mail_account'~~ ✅ Root-Cause behoben (ef90d57); Rest-Failures im vollen Mail-Lauf = IMAP-Calls ohne Mocking → I-E.
- ~~Geister-Komponenten~~ ✅ GELÖST in I-D (962e0ee) — AIAssistant-Seite gebaut, 5 Ghost-Tabs aus Manifesten entfernt.
- ~~Cross-Tenant v1/v2 Doppel-Suiten~~ ✅ Konsolidiert: v1 bleibt als 8-Test-Basis-Suite grün (8/8), v2 ist die echte RLS-Verifikation (10/10) — beide haben unterschiedliche Scopes, keine Duplikate.
- ~~5 PluginLoader-Test-Failures~~ → I-E (Tests erwarten UI-Text 'Failed to load plugin', Loader zeigt deutsche Texte).
- ~~BUG-099~~: workstream.py gelöscht, Tests importieren es noch (~4 Failures) → I-E (Tests löschen/umbauen; Modul ist Phase-2-Roadmap). Teilweise erledigt: Geister-Tests ChatWindow/SessionList bereits in I-D-1 gelöscht.
- ~~~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/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-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.
---
## Übersicht ## Übersicht
| Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung | | Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung |
@@ -297,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.* *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/.
-348
View File
@@ -1,348 +0,0 @@
# LeoCRM UI-Overhaul-Plan (v2)
> **Erstellt:** 2026-08-21
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
> **Status:** Planung — nicht gestartet
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
---
## Standard-Layout (Referenz: ContactsList.tsx)
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
```
┌─────────────┬──────────────────┬──────────────────────┐
│ Tree │ Liste/Ansicht │ Detail │
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
└─────────────┴──────────────────┴──────────────────────┘
```
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
- **Rechts:** Detail-Bereich für ausgewähltes Element
---
## Phase 1: Echte Bugs fixen (2-3 Tage)
### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
- **Datei:** `frontend/src/pages/ContactsList.tsx`
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
- **Aufwand:** 1 Stunde
### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
- **Aufwand:** 3 Stunden
### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
- **Aufwand:** 2 Stunden
### 1.4 Wiki — Artikel kann nicht gespeichert werden
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
- **Aufwand:** 2 Stunden
### 1.5 Kalender — Dialog schließt nicht nach Speichern
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
- **Aufwand:** 30 Minuten
### 1.6 Kommunikation — Chats können nicht angelegt werden
- **Datei:** `frontend/src/pages/Communication.tsx`
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
- **Fix:** Je nach Diagnose
- **Aufwand:** 2 Stunden
### 1.7 Wiki — Doppelt im Menü
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
- **Problem:** Wiki erscheint zweimal im Menü
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
- **Fix:** Doppelte Route entfernen
- **Aufwand:** 30 Minuten
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
---
## Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
### Problem
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
### 2.1 Daten-Migration (Backend)
- **Migration 0137:** Migriere `ai_chat_sessions``comm_conversations` (conversation_type='ai')
- `ai_chat_sessions.id``comm_conversations.id`
- `ai_chat_sessions.title``comm_conversations.title`
- `ai_chat_sessions.tenant_id``comm_conversations.tenant_id`
- `ai_chat_sessions.user_id``comm_conversations.owner_id`
- `ai_chat_sessions.agent_id``comm_conversations.metadata.agent_id`
- `ai_chat_sessions.created_at``comm_conversations.created_at`
- **Migration 0137:** Migriere `ai_chat_messages``comm_messages`
- `ai_chat_messages.id``comm_messages.id`
- `ai_chat_messages.session_id``comm_messages.conversation_id`
- `ai_chat_messages.role``comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
- `ai_chat_messages.content``comm_messages.content`
- `ai_chat_messages.tenant_id``comm_messages.tenant_id`
- **Migration 0137:** Migriere `ai_conversations``comm_conversations` (falls Daten vorhanden)
- **Migration 0137:** Migriere `ai_messages``comm_messages` (falls Daten vorhanden)
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
- **Aufwand:** 1 Tag
### 2.2 Backend — AI Chat API auf Communication umleiten
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
- **Aufwand:** 4 Stunden
### 2.3 Frontend — AI Assistant Page entfernen
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
- **Route anpassen:** `/ai-assistant`**gelöscht** (kein Redirect nötig)
- **Route anpassen:** `/ai-assistant-standalone`**gelöscht** (kein Redirect nötig)
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
- **Aufwand:** 2 Stunden
### 2.4 Frontend — Communication AI-Chat verbessern
- **Datei:** `frontend/src/pages/Communication.tsx`
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
- **Änderung:** AI Chat Messages aus `comm_messages` laden
- **Aufwand:** 4 Stunden
### 2.5 Backend — ai_assistant plugin models aufräumen
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
- **Aufwand:** 2 Stunden
### 2.6 Unified Search — AI Chat Provider anpassen
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
- **Aufwand:** 1 Stunde
**Gesamtaufwand Phase 2:** ~2-3 Tage
---
## Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
### 3.1 WYSIWYG Editor
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
- Floating Toolbar über dem Textblock (wie Notion)
- Markdown-Export für Backend-Speicherung
- **Aufwand:** 2 Tage
### 3.2 Wiki Layout — 3-Spalten
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
- **Aufbau:**
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
- **Aufwand:** 1 Tag
### 3.3 View/Edit Mode Toggle
- **Datei:** `frontend/src/pages/Wiki.tsx`
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
- **Aufwand:** 2 Stunden
**Gesamtaufwand Phase 3:** ~3-4 Tage
---
## Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
### 4.1 Tasks Layout — 3-Spalten wie Kontakte
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
- **Aufbau:**
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
- **Aufwand:** 2-3 Tage
**Gesamtaufwand Phase 4:** ~2-3 Tage
---
## Phase 5: Kalender UI-Überarbeitung (1 Tag)
### 5.1 Toolbar und Filter standardisieren
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
- **Fix:**
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
- Drucken-Button in PluginToolbar
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
- **Aufwand:** 4 Stunden
### 5.2 Kalender-Auswahl fixen
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
- **Aufwand:** 2 Stunden
**Gesamtaufwand Phase 5:** ~1 Tag
---
## Phase 6: Tags Umstrukturierung (2 Tage)
### 6.1 Tags in Settings verschieben
- **Datei:** `frontend/src/pages/Tags.tsx``frontend/src/pages/SettingsTags.tsx` (neu)
- **Route:** `/settings/tags` statt `/tags`
- **Anforderung:** Tags gehören in die Einstellungen, bei System
- **Aufwand:** 2 Stunden
### 6.2 Tags Baumstruktur
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
- **Frontend:** TreeView Komponente für Tags
- **Aufwand:** 1 Tag
### 6.3 Pro Tag einstellbar wo er verfügbar ist
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
- **Frontend:** Multi-Select im Tag-Editor
- **Aufwand:** 4 Stunden
### 6.4 Symbol und Farbe pro Tag
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
- **Aufwand:** 4 Stunden
**Gesamtaufwand Phase 6:** ~2 Tage
---
## Phase 7: Reports UI-Überarbeitung (2 Tage)
### 7.1 Reports Layout — 3-Spalten wie Kontakte
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
- **Aufbau:**
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
- **Aufwand:** 2 Tage
**Gesamtaufwand Phase 7:** ~2 Tage
---
## Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
### 8.1 Baumstruktur verbessern und Ordner
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
- **Aufbau:**
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
- **Aufwand:** 1-2 Tage
### 8.2 AI Chat in Kommunikation (nach Phase 2)
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
- AI Messages werden in `comm_messages` gespeichert
- **Aufwand:** in Phase 2
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
---
## Phase 9: Strukturelle Änderungen (0.5 Tage)
### 9.1 System Dashboard als eigener Menüpunkt
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
- **Aufwand:** 1 Stunde
### 9.2 Mail — Postfach mit IMAP anlegen testen
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
**Gesamtaufwand Phase 9:** ~0.5 Tage
---
## Zusammenfassung
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|-------|--------|---------|-----------|-------------|
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
| 7 | Reports UI | 2 Tage | 0139 | Keine |
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
**Gesamtaufwand:** ~17-22 Tage
### Reihenfolge:
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
8. **Phase 7** (Reports) — großer Umbau, unabhängig
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
### Migrationen:
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
- **0138:** Tags: parent_id, applicable_to, icon Spalten
- **0139:** Reports: folder_id Spalte
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
### Was ich NICHT tun werde:
- Keine Massen-Scripts die neue Fehler verursachen
- Keine Änderungen ohne Verifizierung gegen Produktion
- Keine neuen Plugins wenn bestehende erweitert werden können
- Keine neuen Pages wenn bestehende umgebaut werden können
- Jede Änderung wird mit tsc und API-Test verifiziert
### Was ich brauche:
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
@@ -18,7 +18,26 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the ai_assistant plugin SQL migration has not run
yet when Alembic reaches this revision skip instead of failing.
The plugin-side migration adds the same columns idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "ai_providers"):
# Fresh-install path: table arrives with the ai_assistant plugin
# migration, which includes these columns.
return
op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown")) op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown"))
op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud")) op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud"))
op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none")) op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none"))
@@ -29,6 +48,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "ai_providers"):
return
op.drop_column("ai_providers", "allowed_data_classes") op.drop_column("ai_providers", "allowed_data_classes")
op.drop_column("ai_providers", "transfer_notice") op.drop_column("ai_providers", "transfer_notice")
op.drop_column("ai_providers", "training_on_customer_data") op.drop_column("ai_providers", "training_on_customer_data")
@@ -17,7 +17,23 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision skip the comm_* parts instead
of failing. The plugin-side migration adds the same column idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if _table_exists(conn, "comm_conversations"):
# 1. Add is_system column to comm_conversations # 1. Add is_system column to comm_conversations
op.add_column( op.add_column(
"comm_conversations", "comm_conversations",
@@ -130,13 +146,16 @@ def upgrade() -> None:
AND n.deleted_at IS NULL; AND n.deleted_at IS NULL;
""") """)
# 7. Create legacy view over notifications table for backward compatibility # 7. Legacy view over the CORE notifications table — exists on both paths
op.execute("DROP VIEW IF EXISTS notifications_legacy") op.execute("DROP VIEW IF EXISTS notifications_legacy")
op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications") op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
op.execute("DROP VIEW IF EXISTS notifications_legacy") op.execute("DROP VIEW IF EXISTS notifications_legacy")
if not _table_exists(conn, "comm_conversations"):
return
op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')") op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')")
op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'") op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'")
op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'") op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'")
+17
View File
@@ -14,7 +14,24 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the automation plugin SQL migration has not run yet
when Alembic reaches this revision skip instead of failing. The
plugin-side convergence migration creates the same table.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_runs"):
return
op.create_table( op.create_table(
"automation_agent_run_steps", "automation_agent_run_steps",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True), sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
@@ -18,7 +18,24 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the automation plugin SQL migration has not run yet
when Alembic reaches this revision skip instead of failing. The
plugin-side convergence migration adds the same columns.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_definitions"):
return
op.add_column( op.add_column(
"automation_agent_definitions", "automation_agent_definitions",
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"), sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
@@ -20,7 +20,27 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the tasks plugin SQL migration has not run yet when
Alembic reaches this revision skip instead of failing. The plugin-side
convergence migration adds the same columns/indexes. The legacy-data
backfills below only matter for pre-existing rows and are correctly
empty on a fresh install.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# ── Add new columns to tasks ──────────────────────────────────────────── # ── Add new columns to tasks ────────────────────────────────────────────
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user")) op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
@@ -12,6 +12,7 @@ Revises: 0126
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0127" revision = "0127"
down_revision = "0126" down_revision = "0126"
@@ -19,7 +20,19 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B)."""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# Drop the FK constraint on tasks.contact_id # Drop the FK constraint on tasks.contact_id
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey") op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
@@ -9,6 +9,7 @@ Revises: 0128
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0129" revision = "0129"
down_revision = "0128" down_revision = "0128"
@@ -27,8 +28,25 @@ TABLES_NEEDING_RLS = [
] ]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
for table in TABLES_NEEDING_RLS: for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;") op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
op.execute( op.execute(
f"CREATE POLICY tenant_isolation ON {table} " f"CREATE POLICY tenant_isolation ON {table} "
@@ -37,6 +55,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_NEEDING_RLS: for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
@@ -10,6 +10,7 @@ Revises: 0135
Create Date: 2026-08-21 Create Date: 2026-08-21
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0136" revision = "0136"
down_revision = "0135" down_revision = "0135"
@@ -29,8 +30,25 @@ TABLES_WITH_BAD_RLS = [
] ]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS: for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
# Drop old policy with app.tenant_id # Drop old policy with app.tenant_id
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
# Create new policy with app.current_tenant_id # Create new policy with app.current_tenant_id
@@ -41,7 +59,10 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS: for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute( op.execute(
f"CREATE POLICY tenant_isolation ON {table} " f"CREATE POLICY tenant_isolation ON {table} "
@@ -19,7 +19,25 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the tags plugin SQL migration has not run yet when
Alembic reaches this revision skip instead of failing. The plugin-side
convergence migration adds the same columns.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
# parent_id for tree structure (self-referencing FK) # parent_id for tree structure (self-referencing FK)
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
op.create_foreign_key( op.create_foreign_key(
@@ -35,6 +53,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
op.drop_column("tags", "icon") op.drop_column("tags", "icon")
op.drop_column("tags", "applicable_to") op.drop_column("tags", "applicable_to")
op.drop_index("ix_tags_parent", table_name="tags") op.drop_index("ix_tags_parent", table_name="tags")
@@ -18,11 +18,31 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the report_generator plugin SQL migration has not
run yet when Alembic reaches this revision skip instead of failing.
The plugin-side convergence migration adds the same column.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "report_templates"):
return
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"]) op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "report_templates"):
return
op.drop_index("ix_report_templates_folder", table_name="report_templates") op.drop_index("ix_report_templates_folder", table_name="report_templates")
op.drop_column("report_templates", "folder_id") op.drop_column("report_templates", "folder_id")
@@ -18,11 +18,31 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision skip instead of failing.
The plugin-side migration adds the same column idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "comm_conversations"):
return
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"]) op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "comm_conversations"):
return
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations") op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
op.drop_column("comm_conversations", "folder_id") op.drop_column("comm_conversations", "folder_id")
@@ -0,0 +1,84 @@
'''Fix role permission wildcard patterns to canonical 2-segment schema
Revision ID: 0141
Revises: 0140
Create Date: 2026-08-23
Migration 0019 seeded default roles with 3-segment permission patterns
(core:*:read etc.). The runtime matcher (_matches_permission) compares
segment counts strictly, so those patterns could never match any
2-segment requirement - editor/viewer roles were silently dead.
Canonical schema is module:action (2 segments, * wildcards allowed).
core:*:X means all modules with action X, so it converts to *:X.
'''
from alembic import op
# revision identifiers, used by Alembic.
revision = '0141'
down_revision = '0140'
branch_labels = None
depends_on = None
# Rebuild the permissions JSONB object, rewriting every key that starts
# with the dead 'core:' prefix to its 2-segment equivalent ('*:X').
_UPGRADE_SQL = '''
UPDATE roles
SET permissions = sub.new_perms,
permission_version = permission_version + 1
FROM (
SELECT
r.id AS role_id,
jsonb_object_agg(
CASE WHEN k LIKE 'core:%'
THEN '*:' || split_part(k, ':', 3)
ELSE k END,
v
) AS new_perms
FROM roles r,
jsonb_each(r.permissions) AS e(k, v)
GROUP BY r.id
) AS sub
WHERE roles.id = sub.role_id
AND EXISTS (
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
WHERE k LIKE 'core:%'
)
'''
# Reverse: map '*:X' back to 'core:*:X' only for keys that came from the
# original seeding pattern. Roles that legitimately use '*:X' without a
# matching 'core:*:X' history are left untouched (best-effort downgrade).
_DOWNGRADE_SQL = '''
UPDATE roles
SET permissions = sub.new_perms,
permission_version = permission_version + 1
FROM (
SELECT
r.id AS role_id,
jsonb_object_agg(
CASE WHEN k = '*:' || split_part(k, ':', 2)
AND k <> '*:*'
THEN 'core:*:' || split_part(k, ':', 2)
ELSE k END,
v
) AS new_perms
FROM roles r,
jsonb_each(r.permissions) AS e(k, v)
GROUP BY r.id
) AS sub
WHERE roles.id = sub.role_id
AND EXISTS (
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
WHERE k = '*:' || split_part(k, ':', 2) AND k <> '*:*'
)
'''
def upgrade() -> None:
op.execute(_UPGRADE_SQL)
def downgrade() -> None:
op.execute(_DOWNGRADE_SQL)
@@ -0,0 +1,39 @@
"""Add backup config columns to system_settings table.
Follow-up to 0130: the backup feature (10b1f83) added backup_interval,
backup_retention_days and backup_destination to schema/service/frontend
but missed model columns and this migration.
Revision ID: 0142
Revises: 0141
"""
import sqlalchemy as sa
from alembic import op
revision = "0142"
down_revision = "0141"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"system_settings",
sa.Column("backup_interval", sa.String(20), nullable=False, server_default="daily"),
)
op.add_column(
"system_settings",
sa.Column("backup_retention_days", sa.Integer(), nullable=False, server_default="7"),
)
op.add_column(
"system_settings",
sa.Column("backup_destination", sa.String(20), nullable=False, server_default="local"),
)
def downgrade() -> None:
op.drop_column("system_settings", "backup_destination")
op.drop_column("system_settings", "backup_retention_days")
op.drop_column("system_settings", "backup_interval")
@@ -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)"
)
+148 -2
View File
@@ -122,6 +122,10 @@ async def _execute_tool(
"""Execute a single tool call via the registry. """Execute a single tool call via the registry.
Returns the tool result as a string, or an error message. 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) tool = tool_registry.get(tool_name)
if tool is None: if tool is None:
@@ -135,6 +139,131 @@ async def _execute_tool(
return f"Error: {exc}" 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( async def run_react_loop(
agent_definition: Any, # AgentDefinition from automation models agent_definition: Any, # AgentDefinition from automation models
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
@@ -151,6 +280,7 @@ async def run_react_loop(
dry_run: bool = False, dry_run: bool = False,
require_approval: bool = False, require_approval: bool = False,
approval_tools: list[str] | None = None, approval_tools: list[str] | None = None,
user_permissions: dict[str, Any] | None = None,
) -> ReActResult: ) -> ReActResult:
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat. """Execute a ReAct loop: LLM reasoning → tool execution → repeat.
@@ -197,8 +327,13 @@ async def run_react_loop(
"tenant_id": str(tenant_id), "tenant_id": str(tenant_id),
"user_id": str(user_id), "user_id": str(user_id),
"db": db, "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. # Audit helper — records every tool call in the audit log.
async def _audit_tool_call( async def _audit_tool_call(
step_number: int, step_number: int,
@@ -365,7 +500,15 @@ async def run_react_loop(
args = {} args = {}
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"]) 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( observation = json.dumps(
{ {
"dry_run": True, "dry_run": True,
@@ -394,7 +537,7 @@ async def run_react_loop(
if agent_run_id: if agent_run_id:
try: try:
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
komm = get_contract_registry().get("kommunikation") komm = get_contract_registry().get_contract("kommunikation")
if komm: if komm:
agent_id = getattr(agent_definition, "id", uuid.uuid4()) agent_id = getattr(agent_definition, "id", uuid.uuid4())
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}" 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}"}) observation = json.dumps({"error": f"Approval required but failed to create request: {e}"})
else: else:
observation = await _execute_tool(tool_registry, tool_name, args, tool_context) 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) observations.append(observation)
# Audit every tool call (real or simulated) # Audit every tool call (real or simulated)
+1 -1
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
+4 -1
View File
@@ -20,7 +20,8 @@ import asyncio
import json import json
import logging import logging
import uuid 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 from app.ai.agent_loop import ReActStep, run_react_loop
@@ -71,6 +72,7 @@ async def stream_react_loop(
max_steps: int = 20, max_steps: int = 20,
timeout_seconds: int = 300, timeout_seconds: int = 300,
trace_id: str | None = None, trace_id: str | None = None,
user_permissions: dict[str, Any] | None = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Run the ReAct loop and yield SSE-formatted events. """Run the ReAct loop and yield SSE-formatted events.
@@ -116,6 +118,7 @@ async def stream_react_loop(
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
trace_id=trace_id, trace_id=trace_id,
on_step=on_step, on_step=on_step,
user_permissions=user_permissions, # F01: enforce at execution time
) )
await queue.put( await queue.put(
_sse( _sse(
-2
View File
@@ -25,8 +25,6 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.core.sensitive_data import sanitize_dict
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+46 -2
View File
@@ -20,8 +20,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.ai_use_case import AIUseCaseMetadata from app.ai.ai_use_case import AIUseCaseMetadata
from app.core.sensitive_data import ( from app.core.sensitive_data import (
SENSITIVE_FIELDS, SENSITIVE_FIELDS,
filter_for_llm_context,
get_data_class_for_field,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -84,6 +82,14 @@ async def enforce_data_policy(
else c else c
for c in content 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 = dict(msg)
new_msg["content"] = content new_msg["content"] = content
filtered.append(new_msg) filtered.append(new_msg)
@@ -91,6 +97,44 @@ async def enforce_data_policy(
return filtered 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( def _filter_dict_content(
data: dict[str, Any], data: dict[str, Any],
metadata: AIUseCaseMetadata, metadata: AIUseCaseMetadata,
-1
View File
@@ -6,7 +6,6 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
LOW_CONFIDENCE_THRESHOLD = 0.6 LOW_CONFIDENCE_THRESHOLD = 0.6
-1
View File
@@ -7,7 +7,6 @@ from typing import Any
from app.ai.knowledge_sources import get_source_config from app.ai.knowledge_sources import get_source_config
EXTRACTION_TRIGGERS = { EXTRACTION_TRIGGERS = {
"mail.received", "mail.received",
"dms.file_uploaded", "dms.file_uploaded",
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
+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",
)
+4
View File
@@ -107,6 +107,10 @@ class Settings(BaseSettings):
rate_limit_webhook_max: int = 100 # incoming webhooks rate_limit_webhook_max: int = 100 # incoming webhooks
rate_limit_webhook_window: int = 60 # 1 minute rate_limit_webhook_window: int = 60 # 1 minute
# System tenant — used by seeding/plugins that need a well-known default
# tenant (must match scripts/seed_admin.py slug).
system_tenant_slug: str = "default"
# LLM Cost Overrun Protection (B.17) # LLM Cost Overrun Protection (B.17)
llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget
llm_hard_cutoff: bool = True # block LLM calls when budget exceeded llm_hard_cutoff: bool = True # block LLM calls when budget exceeded
+120 -7
View File
@@ -52,6 +52,12 @@ class ApprovalRequest(Base, TenantMixin):
PGUUID(as_uuid=True), nullable=True PGUUID(as_uuid=True), nullable=True
) )
approver_group: Mapped[str | None] = mapped_column(String(120), 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( status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending" String(20), nullable=False, default="pending"
) )
@@ -103,6 +109,41 @@ async def create_approval_request(
return req 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( async def resolve_approval_request(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
@@ -111,12 +152,31 @@ async def resolve_approval_request(
decision: str, decision: str,
approver_id: uuid.UUID, approver_id: uuid.UUID,
comment: str | None = None, comment: str | None = None,
is_system_admin: bool = False,
) -> ApprovalRequest | None: ) -> 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( result = await db.execute(
select(ApprovalRequest).where( select(ApprovalRequest).where(
@@ -125,14 +185,67 @@ async def resolve_approval_request(
) )
) )
req = result.scalar_one_or_none() req = result.scalar_one_or_none()
if req is None or req.status != "pending": if req is None:
return None return None
req.status = decision # 1. Expiry check — an expired request can no longer be decided.
req.approver_id = approver_id if (
req.comment = comment 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) req.resolved_at = datetime.now(UTC)
await db.flush() 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 return req
+81
View File
@@ -85,6 +85,87 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
"""Delete every active Redis session belonging to the user (G2).
Shared by both password-change paths (token reset + profile/admin change):
after a password change, stolen or lingering sessions must die.
Returns the number of deleted session keys. Never raises a Redis outage
must not break the password change itself.
"""
try:
redis = get_redis()
deleted = 0
async for key in redis.scan_iter(match="session:*", count=100):
raw = await redis.get(key)
if raw is None:
continue
try:
import json
session_data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if session_data.get("user_id") == str(user_id):
await redis.delete(key)
deleted += 1
logger.info("Deleted session %s for user %s", key, user_id)
return deleted
except Exception:
logger.warning("Failed to invalidate Redis sessions for user %s", user_id, exc_info=True)
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: def hash_token(token: str) -> str:
"""SHA-256 hash a token for storage.""" """SHA-256 hash a token for storage."""
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
+17
View File
@@ -355,6 +355,23 @@ async def close_engine() -> None:
_migration_session_factory = None _migration_session_factory = None
async def get_system_tenant(db: AsyncSession):
"""Return the well-known system tenant, or ``None`` if it does not exist.
Resolves by configured slug (``settings.system_tenant_slug``, default
``"default"`` as created by ``scripts/seed_admin.py``) instead of an
arbitrary first row, so multi-tenant databases stay deterministic.
"""
from sqlalchemy import select
from app.config import get_settings
from app.models.tenant import Tenant # lazy: models import this module's Base
slug = get_settings().system_tenant_slug
result = await db.execute(select(Tenant).where(Tenant.slug == slug).limit(1))
return result.scalar_one_or_none()
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Replace all global engines with a test engine. Returns a session factory. """Replace all global engines with a test engine. Returns a session factory.
+6 -1
View File
@@ -36,7 +36,12 @@ class EventBus:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list) self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None: def subscribe(self, event_name: str, handler: EventHandler) -> None:
"""Subscribe a handler to an event.""" """Subscribe a handler to an event.
Idempotent: subscribing the same handler twice is a no-op
(ARCH-020) so double activation cannot fire handlers twice.
"""
if handler not in self._handlers[event_name]:
self._handlers[event_name].append(handler) self._handlers[event_name].append(handler)
def unsubscribe(self, event_name: str, handler: EventHandler) -> None: def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
+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
+281
View File
@@ -153,3 +153,284 @@ async def send_password_reset_email(
from app.core.job_registry import register_job # noqa: E402 from app.core.job_registry import register_job # noqa: E402
register_job("send_password_reset_email", send_password_reset_email) register_job("send_password_reset_email", send_password_reset_email)
# ── DSAR Processing Job (G1 DSGVO: Art. 15 Auskunft / Art. 17 Löschung) ─────
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.
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 select as sa_select
from app.models.audit import AuditLog
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)
export_data: dict[str, Any] = {
"user_id": user_id,
"exported_at": datetime.now(UTC).isoformat(),
"legal_basis": "GDPR Art. 15 (access) / Art. 20 (portability)",
"data": {},
}
# Profile
user = (
await db.execute(sa_select(User).where(User.id == uid))
).scalar_one_or_none()
if user:
export_data["data"]["profile"] = {
"email": user.email,
"name": user.name,
"is_active": user.is_active,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
# Audit trail entries by/about the user (bounded to keep payloads sane)
audit_entries = (
await db.execute(
sa_select(AuditLog).where(
AuditLog.tenant_id == tid,
AuditLog.user_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["audit_log"] = [
{
"action": a.action,
"entity_type": a.entity_type,
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
}
for a in audit_entries
]
# Notifications addressed to the user
notifications = (
await db.execute(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["notifications"] = [
{
"id": str(n.id),
"type": getattr(n, "type", None),
"title": getattr(n, "title", None),
"created_at": n.created_at.isoformat() if n.created_at else None,
}
for n in notifications
]
# ── 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,
)
return export_data
async def _dsar_execute_deletion(db: Any, tenant_id: str, user_id: str) -> dict[str, int]:
"""Execute GDPR Art. 17 erasure for a user within one tenant.
Strategy (respects retention duties):
- 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 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.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. 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(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
)
)
notifications = notif_result.scalars().all()
for n in notifications:
await db.delete(n)
counts["notifications_deleted"] = len(notifications)
# 3. Anonymize + deactivate the account (FK integrity for audit rows kept)
await db.execute(
sa_update(User)
.where(User.id == uid)
.values(
email=f"erased.{uid.hex[:16]}@anonymized.invalid",
name="[gelöscht gemäß DSGVO Art. 17]",
first_name=None,
last_name=None,
avatar_url=None,
password_hash="!dsar-erased",
is_active=False,
preferences={},
)
)
counts["user_anonymized"] = 1
# 4. Audit the erasure itself (who/what/when — required by Art. 17 recital)
await log_audit(
db,
tid,
user_id,
"dsar_erasure",
"user",
uid,
{"target_user": user_id, **counts},
)
return counts
async def process_dsar(
ctx: dict[str, Any],
*,
user_id: str,
tenant_id: str,
request_type: str,
) -> dict[str, Any]:
"""Process a GDPR Data Subject Access Request (DSAR).
ARQ worker function registered as "process_dsar".
request_type:
- "access": collect all data categories (Art. 15/20) and post a system
message that the export is ready (served via the existing dsgvo-export
endpoint).
- "deletion": execute Art. 17 erasure (soft-delete contacts, hard-delete
notifications, anonymize+deactivate account) and audit it.
- "rectification": post a system message asking admins to handle the
correction manually.
Returns a summary dict for the job result.
"""
import logging
import uuid as uuid_module
from app.core.db import get_worker_session_factory
from app.core.notifications import post_system_message
logger = logging.getLogger(__name__)
tid = uuid_module.UUID(tenant_id)
uid = uuid_module.UUID(user_id)
factory = get_worker_session_factory()
async with factory() as db:
try:
if request_type == "access":
data = await _dsar_collect_user_data(db, tenant_id, user_id)
await db.commit()
categories = list(data.get("data", {}).keys())
await post_system_message(
db,
tid,
uid,
"dsar_access_ready",
"DSGVO-Auskunft bereit",
f"Datenkategorien: {', '.join(categories)}",
severity="info",
)
await db.commit()
logger.info("DSAR access processed for user %s", user_id)
return {"type": request_type, "status": "completed", "categories": categories}
if request_type == "deletion":
counts = await _dsar_execute_deletion(db, tenant_id, user_id)
await db.commit()
await post_system_message(
db,
tid,
uid,
"dsar_deletion_done",
"DSGVO-Löschung ausgeführt",
f"Kontakten soft-gelöscht: {counts.get('contacts_soft_deleted', 0)}; Konto anonymisiert.",
severity="info",
)
await db.commit()
logger.info("DSAR deletion executed for user %s: %s", user_id, counts)
return {"type": request_type, "status": "completed", **counts}
if request_type == "rectification":
await post_system_message(
db,
tid,
uid,
"dsar_rectification_requested",
"DSGVO-Berichtigung angefordert",
f"Manuelle Bearbeitung für User {user_id} erforderlich.",
severity="warning",
)
await db.commit()
logger.info("DSAR rectification requested for user %s", user_id)
return {"type": request_type, "status": "queued_for_manual_handling"}
logger.warning("Unknown DSAR request_type '%s' for user %s", request_type, user_id)
return {"type": request_type, "status": "unknown_type"}
except Exception:
await db.rollback()
raise
register_job("process_dsar", process_dsar)
+113
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import uuid as uuid_mod
from fastapi import Request, status from fastapi import Request, status
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -72,6 +74,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if request.headers.get("upgrade", "").lower() == "websocket": if request.headers.get("upgrade", "").lower() == "websocket":
return await call_next(request) 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: if request.method in self.UNSAFE_METHODS:
# 1. Origin header check # 1. Origin header check
origin = request.headers.get("origin") origin = request.headers.get("origin")
@@ -137,3 +156,97 @@ class CSRFMiddleware(BaseHTTPMiddleware):
pass pass
return await call_next(request) return await call_next(request)
class AuditMiddleware(BaseHTTPMiddleware):
"""Safety-net audit trail for ALL successful mutating requests.
AGENTS.md requires every mutation to produce an audit entry. Explicit
``log_audit`` calls in routes/services remain the detail layer (entity ids,
change diffs); this middleware guarantees a baseline entry for mutations
that lack one, marked with ``source=middleware`` in ``details``.
Best-effort by design: audit failures never break the request.
"""
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
_SKIP_PREFIXES = (
"/api/v1/auth",
"/api/v1/health",
"/api/v1/errors",
"/api/v1/audit",
"/api/v1/external",
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.method not in self._MUTATING:
return response
if response.status_code < 200 or response.status_code >= 300:
return response
path = request.url.path
if any(path.startswith(p) for p in self._SKIP_PREFIXES):
return response
try:
await self._write_entry(request, path, response.status_code)
except Exception:
logging.getLogger(__name__).debug(
"AuditMiddleware: failed to write baseline entry for %s %s", request.method, path
)
return response
@staticmethod
def _derive_entity_type(path: str) -> str:
"""Derive an entity_type from the second URL segment."""
parts = [p for p in path.split("/") if p]
# /api/v1/<resource>/... -> resource; singularize naive trailing 's'
resource = parts[2] if len(parts) > 2 and parts[0] == "api" and parts[1] == "v1" else (parts[0] if parts else "unknown")
return resource[:-1] if len(resource) > 3 and resource.endswith("s") else resource
async def _write_entry(self, request: Request, path: str, status_code: int) -> None:
from app.core.audit import log_audit
from app.core.auth import get_redis, get_session_data
from app.core.db import create_db_session
# Attribute via the Redis session (same source as CSRFMiddleware) —
# FastAPI dependencies run after middleware, so request.state is empty here.
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
return # unauthenticated — nothing to attribute
redis = get_redis()
session_data = await get_session_data(redis, session_id)
if not session_data:
return
tenant_raw = session_data.get("tenant_id")
user_raw = session_data.get("user_id")
if not tenant_raw:
return
action_map = {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}
entity_id: uuid_mod.UUID | None = None
parts = [p for p in path.split("/") if p]
if parts and re.fullmatch(r"[0-9a-fA-F-]{36}", parts[-1]):
try:
entity_id = uuid_mod.UUID(parts[-1])
except ValueError:
entity_id = None
async with create_db_session(uuid_mod.UUID(tenant_raw)) as db:
await log_audit(
db,
uuid_mod.UUID(tenant_raw),
uuid_mod.UUID(user_raw) if user_raw else None,
action_map.get(request.method, request.method.lower()),
self._derive_entity_type(path),
entity_id,
changes={
"source": "middleware",
"method": request.method,
"path": path,
"status": status_code,
},
)
await db.commit()
+15 -1
View File
@@ -212,7 +212,21 @@ def _json_payload(payload: dict[str, Any]) -> str:
def _get_handler_name(handler: 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) name = getattr(handler, "__name__", None)
if name: if name:
return name return name
+37 -45
View File
@@ -54,6 +54,8 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"}, {"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"},
{"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"}, {"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"},
{"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"}, {"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"},
{"key": "custom_fields:read", "label": "Custom Fields: Read", "category": "core", "module": "custom_fields"},
{"key": "custom_fields:write", "label": "Custom Fields: Write", "category": "core", "module": "custom_fields"},
{"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"}, {"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"},
{"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"}, {"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
{"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"}, {"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"},
@@ -62,9 +64,25 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"}, {"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "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": "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"}, {"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, # 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 # are registered dynamically via register_plugin_permissions() from plugin
# manifests at activation time. They are NOT hardcoded here (P0-4 fix). # manifests at activation time. They are NOT hardcoded here (P0-4 fix).
] ]
@@ -72,50 +90,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
# ── Core field definitions for field-level permissions ── # ── Core field definitions for field-level permissions ──
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [ CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
# ── Contact fields ── # Audit P1/P2 (contact field definitions): all contacts:* field
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"}, # definitions moved to the ContactsPlugin manifest (field_definitions=)
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"}, # so the plugin fully owns its field structure. The core keeps only
{"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"}, # genuinely core-owned fields (users). Plugin field definitions are
{"module": "contacts", "field": "name", "label": "Name", "sensitivity": "normal"}, # registered at activation time via register_field_definitions().
{"module": "contacts", "field": "email_1", "label": "Email 1", "sensitivity": "normal"}, # ── User fields (core-owned) ──
{"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 ──
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"}, {"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"}, {"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"}, {"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
@@ -222,6 +202,18 @@ class PermissionRegistry:
self._field_definitions[plugin_name] = field_defs self._field_definitions[plugin_name] = field_defs
logger.info("Registered %d field definitions for plugin '%s'", len(field_defs), plugin_name) 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]]: def get_all_field_definitions(self) -> list[dict[str, str]]:
"""Return all registered field definitions.""" """Return all registered field definitions."""
result = list(self._core_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 return True
permissions = set(resolved.get("permissions", [])) 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 # Check deny list first
for d in denied: for d in denied:
+7 -4
View File
@@ -173,12 +173,15 @@ def _derive_policy_from_sensitivity(
if field_name in entity_policy: if field_name in entity_policy:
return dict(entity_policy[field_name]) return dict(entity_policy[field_name])
# Try to get sensitivity from permission registry (lazy import to avoid # Try to get sensitivity from the permission registry (lazy import to
# circular dependencies at module load time). # 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: 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: if fd.get("module") == entity_type and fd.get("field") == field_name:
sensitivity = fd.get("sensitivity", "normal") sensitivity = fd.get("sensitivity", "normal")
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED)) return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
+8
View File
@@ -29,6 +29,14 @@ class ServiceContainer:
"""Check if a service is registered.""" """Check if a service is registered."""
return name in self._services return name in self._services
def remove(self, name: str) -> None:
"""Remove a service registration (no-op if absent).
Used by plugin deactivation hooks to clean up services they
registered during activation.
"""
self._services.pop(name, None)
async def initialize(self) -> None: async def initialize(self) -> None:
"""Initialize core services.""" """Initialize core services."""
if self._initialized: if self._initialized:
+40 -12
View File
@@ -501,11 +501,38 @@ async def save_with_metadata(
} }
async def get_file_metadata_async(path: str) -> dict[str, Any]:
"""Awaitable variant of :func:`get_file_metadata` (ARCH-052).
Safe to call from inside a running event loop never creates a
nested one. For local storage this is plain filesystem access; for
S3 and other async backends the backend's ``exists()`` is awaited.
"""
backend = get_storage_backend()
if isinstance(backend, LocalStorage):
full_path = backend._full_path(path)
if not os.path.exists(full_path):
return {"size": None, "modified": None, "exists": False}
stat = os.stat(full_path)
return {
"size": stat.st_size,
"modified": stat.st_mtime,
"exists": True,
}
# S3 or other async backends — await the backend directly
if not await backend.exists(path):
return {"size": None, "modified": None, "exists": False}
return {"size": None, "modified": None, "exists": True}
def get_file_metadata(path: str) -> dict[str, Any]: def get_file_metadata(path: str) -> dict[str, Any]:
"""Read metadata of a stored file without loading its content. """Read metadata of a stored file without loading its content.
Works with the *local* storage backend. For S3, use the S3 client Works with the *local* storage backend without touching the event
``stat_object`` API directly. loop. For S3 and other async-only backends this drives the check
through ``asyncio.run``; calling it from inside a running event loop
raises ``RuntimeError`` use :func:`get_file_metadata_async` there
instead (ARCH-052).
Parameters Parameters
---------- ----------
@@ -530,14 +557,15 @@ def get_file_metadata(path: str) -> dict[str, Any]:
"modified": stat.st_mtime, "modified": stat.st_mtime,
"exists": True, "exists": True,
} }
# S3 or other backends — fall back to exists() check # Async-only backend outside a running loop is fine; inside one we
import asyncio as _asyncio # must never build a nested event loop.
loop = _asyncio.new_event_loop()
try: try:
exists = loop.run_until_complete(backend.exists(path)) asyncio.get_running_loop()
if not exists: except RuntimeError:
return {"size": None, "modified": None, "exists": False} pass
return {"size": None, "modified": None, "exists": True} else:
finally: raise RuntimeError(
loop.close() "get_file_metadata() cannot be used with async storage backends "
"inside a running event loop — use get_file_metadata_async()"
)
return asyncio.run(get_file_metadata_async(path))
+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,
)
+4 -1
View File
@@ -121,11 +121,14 @@ class TriggerDispatcher:
"""Query DB for active automations matching *event_name* and dispatch.""" """Query DB for active automations matching *event_name* and dispatch."""
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
# None-check FIRST — accessing attributes on the contract before the
# check crashed with AttributeError when automation was inactive
# (ARCH-029/041).
automation_contract = get_contract("automation") automation_contract = get_contract("automation")
AutomationDefinition = automation_contract.Automation # noqa: N806
if automation_contract is None: if automation_contract is None:
logger.debug("Automation plugin not available — trigger skipped") logger.debug("Automation plugin not available — trigger skipped")
return return
AutomationDefinition = automation_contract.Automation # noqa: N806
factory = get_session_factory() factory = get_session_factory()
tenant_id = payload.get("tenant_id") tenant_id = payload.get("tenant_id")
+8 -2
View File
@@ -7,7 +7,8 @@ import logging
import uuid import uuid
from typing import Any 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.db import get_session_factory
from app.core.event_bus import EventBus, get_event_bus 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( stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id, Webhook.tenant_id == tenant_id,
Webhook.is_active == True, # noqa: E712 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) result = await db.execute(stmt)
webhooks = list(result.scalars().all()) webhooks = list(result.scalars().all())
+19 -22
View File
@@ -170,12 +170,7 @@ async def on_startup(ctx: dict[str, Any]) -> None:
if search_contract is not None: if search_contract is not None:
factory = async_session factory = async_session
async with factory() as db: async with factory() as db:
# auto_register_providers is not exposed via contract yet; await search_contract.auto_register_providers(db)
# use the contract's get_search_registry to access providers
from app.plugins.builtins.unified_search.provider_registry import (
auto_register_providers,
)
await auto_register_providers(db)
logger.info("Search providers registered for worker") logger.info("Search providers registered for worker")
else: else:
logger.debug("Unified search plugin not available — skipping provider registration") logger.debug("Unified search plugin not available — skipping provider registration")
@@ -350,8 +345,10 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
Runs daily to prevent the audit_log table from growing indefinitely. Runs daily to prevent the audit_log table from growing indefinitely.
Iterates per-tenant for RLS compliance. Iterates per-tenant for RLS compliance.
""" """
from sqlalchemy import text as sa_text, delete as sa_delete from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.audit import AuditLog from app.models.audit import AuditLog
@@ -362,7 +359,7 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result] tenant_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=365) cutoff = datetime.now(UTC) - timedelta(days=365)
total_deleted = 0 total_deleted = 0
for tenant_id in tenant_ids: for tenant_id in tenant_ids:
await db.execute( await db.execute(
@@ -393,11 +390,12 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
Runs daily to clean up the trash. Iterates per-tenant for RLS compliance. Runs daily to clean up the trash. Iterates per-tenant for RLS compliance.
Default retention: 90 days in trash before permanent deletion. Default retention: 90 days in trash before permanent deletion.
""" """
from sqlalchemy import text as sa_text, delete as sa_delete from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.contact import Contact
from app.models.entity_attachment import EntityAttachment from app.models.entity_attachment import EntityAttachment
factory = get_worker_session_factory() factory = get_worker_session_factory()
@@ -406,7 +404,7 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result] tenant_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=90) cutoff = datetime.now(UTC) - timedelta(days=90)
total_deleted = 0 total_deleted = 0
for tenant_id in tenant_ids: for tenant_id in tenant_ids:
@@ -415,16 +413,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
{"tid": str(tenant_id)}, {"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 # 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( result = await db.execute(
sa_delete(EntityAttachment).where( sa_delete(EntityAttachment).where(
EntityAttachment.deleted_at.is_not(None), EntityAttachment.deleted_at.is_not(None),
@@ -489,6 +480,12 @@ class WorkerSettings:
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300), _wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
hour=4, minute=0, 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). # Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
# Function comes from the knowledge plugin via the job registry. # Function comes from the knowledge plugin via the job registry.
cron( cron(
+195 -19
View File
@@ -7,7 +7,7 @@ import uuid
from typing import Any from typing import Any
import redis.asyncio as aioredis 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,8 +17,9 @@ from app.core.db import get_db, set_tenant_context, set_user_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Known write-permission modules — used by require_write() to check # Legacy fallback list — used by require_write() only when the permission
# specific permissions instead of broad wildcards like *:write # registry is not initialized. The live source of truth is generated from
# the registry (see _get_write_permissions, ARCH-022).
_WRITE_PERMISSIONS = [ _WRITE_PERMISSIONS = [
"users:write", "users:write",
"roles:write", "roles:write",
@@ -35,6 +36,30 @@ _WRITE_PERMISSIONS = [
] ]
def _get_write_permissions() -> list[str]:
"""Return all known ``module:write`` permission keys (ARCH-022).
Generated from the permission registry so plugin write permissions are
picked up automatically without touching this file. Falls back to the
static legacy list when the registry is unavailable/uninitialized.
"""
try:
from app.core.permission_registry import get_permission_registry
registry = get_permission_registry()
if getattr(registry, "_initialized", False):
perms = [
entry["key"]
for entry in registry.get_all()
if entry["key"].endswith(":write")
]
if perms:
return sorted(perms)
except Exception:
pass
return list(_WRITE_PERMISSIONS)
async def get_redis_dep() -> aioredis.Redis: async def get_redis_dep() -> aioredis.Redis:
"""FastAPI dependency for Redis client.""" """FastAPI dependency for Redis client."""
return get_redis() return get_redis()
@@ -49,7 +74,62 @@ async def get_current_user(
Returns session data dict with user_id, tenant_id, email, name, role, Returns session data dict with user_id, tenant_id, email, name, role,
and resolved permissions from Redis cache. 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() settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name) session_id = request.cookies.get(settings.session_cookie_name)
@@ -105,10 +185,20 @@ async def get_current_user(
membership_row = membership_q.first() membership_row = membership_q.first()
membership_status = membership_row[0] if membership_row else None membership_status = membership_row[0] if membership_row else None
role_id = membership_row[1] 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Cache user principals for this request — avoids N+1 queries in visibility.py
@@ -222,6 +312,54 @@ async def get_current_user_or_bearer(
return await get_current_user(request, db, redis) 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( async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -261,7 +399,7 @@ async def require_write(
# Check via permission system for specific write permissions # Check via permission system for specific write permissions
from app.core.permissions import check_permission from app.core.permissions import check_permission
for perm in _WRITE_PERMISSIONS: for perm in _get_write_permissions():
if check_permission(current_user, perm): if check_permission(current_user, perm):
return current_user return current_user
@@ -286,6 +424,10 @@ def require_permission(permission: str):
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
# API token scope enforcement (Problem 2 fix) # 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") token_scopes = current_user.get("_token_scopes")
if token_scopes is not None: if token_scopes is not None:
from app.core.permissions import _permission_matches_any from app.core.permissions import _permission_matches_any
@@ -297,7 +439,7 @@ def require_permission(permission: str):
"code": "insufficient_scope", "code": "insufficient_scope",
}, },
) )
return current_user # fall through: the normal user-permission check applies too
if current_user.get("is_system_admin"): if current_user.get("is_system_admin"):
return current_user return current_user
@@ -357,6 +499,30 @@ async def get_current_user_id(
return uuid.UUID(current_user["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): def require_active_plugin(plugin_name: str):
"""FastAPI dependency factory: require that a plugin is active. """FastAPI dependency factory: require that a plugin is active.
@@ -375,7 +541,16 @@ def require_active_plugin(plugin_name: str):
""" """
async def _check( async def _check(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
) -> None: ) -> 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 from app.core.permission_registry import get_permission_registry
try: try:
registry = get_permission_registry() registry = get_permission_registry()
@@ -387,19 +562,20 @@ def require_active_plugin(plugin_name: str):
"code": "plugin_inactive", "code": "plugin_inactive",
}, },
) )
# Get tenant_id from existing db session (NOT a new session) # Tenant comes from the AUTHENTICATED user context — never from
# The tenant context is set by middleware/get_current_user on this same session # the DB session (which may not have the context set yet).
from sqlalchemy import text as sa_text raw_tid = current_user.get("tenant_id")
if not raw_tid:
result = await db.execute( # Fail-closed: no authenticated tenant context → reject.
sa_text("SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid") # (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 = result.scalar() tenant_id = uuid.UUID(str(raw_tid))
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
# Per-tenant activation check with Redis cache # Per-tenant activation check with Redis cache
import json import json
+67 -24
View File
@@ -22,8 +22,12 @@ logger = logging.getLogger(__name__)
from app.config import get_settings # noqa: E402 from app.config import get_settings # noqa: E402
from app.core.db import close_engine, get_engine # noqa: E402 from app.core.db import close_engine, get_engine # noqa: E402
from app.core.error_codes import ApiError, build_error_response # noqa: E402 from app.core.error_codes import ERROR_CODES, ApiError, build_error_response # noqa: E402
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402 from app.core.middleware import ( # noqa: E402
AuditMiddleware,
CSRFMiddleware,
SecurityHeadersMiddleware,
)
from app.core.monitoring import record_error, record_request # noqa: E402 from app.core.monitoring import record_error, record_request # noqa: E402
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402 from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402 from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
@@ -36,16 +40,14 @@ from app.routes import ( # noqa: E402
attachments, attachments,
audit, audit,
auth, auth,
compliance,
backups, backups,
bank_accounts, bank_accounts,
contact_folder_permissions, compliance,
contact_folders,
contacts,
currencies, currencies,
custom_field_definitions, custom_field_definitions,
custom_fields,
dashboard, dashboard,
dashboards,
delegations,
entity_history, entity_history,
entity_permissions, entity_permissions,
errors, errors,
@@ -54,12 +56,12 @@ from app.routes import ( # noqa: E402
health, health,
import_export, import_export,
metrics, metrics,
miniapps,
notifications, notifications,
outbox, outbox,
owner_transfer, owner_transfer,
permission_templates, permission_templates,
plugins, plugins,
delegations,
policies, policies,
roles, roles,
saved_filters, saved_filters,
@@ -216,6 +218,16 @@ async def lifespan(app: FastAPI):
registry.initialize(get_migration_engine(), app) registry.initialize(get_migration_engine(), app)
registry.discover_builtins() 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 # Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -284,22 +296,23 @@ async def lifespan(app: FastAPI):
logger.info(f"Plugin {name} is inactive — skipping activation") logger.info(f"Plugin {name} is inactive — skipping activation")
continue continue
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage # Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
# RLS fail-closed requires app.current_tenant_id for tenant-table writes. # the first tenant's RLS context satisfies fail-closed RLS for any
# Plugin activation may fail on duplicate cron job inserts — this is harmless # tenant-table writes during activation. Plugins that need per-tenant
# since cron jobs already exist from previous startups. # data must seed it themselves (e.g. via the default-tenant mechanism).
# Calling on_activate once prevents duplicate event listeners, cron
# jobs, mini-apps and other contributions at multi-tenant startups.
plugin_activated = False plugin_activated = False
for tenant_id in all_tenant_ids: if all_tenant_ids:
try: try:
async with async_session() as plugin_db: async with async_session() as plugin_db:
await set_tenant_context(plugin_db, tenant_id) await set_tenant_context(plugin_db, all_tenant_ids[0])
await plugin.on_activate(plugin_db, container, event_bus) await plugin.on_activate(plugin_db, container, event_bus)
await plugin_db.flush() await plugin_db.flush()
await plugin_db.commit() await plugin_db.commit()
plugin_activated = True plugin_activated = True
except Exception as exc: except Exception as exc:
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}") logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
break
if plugin_activated: if plugin_activated:
plugin_record.status = "active" plugin_record.status = "active"
@@ -324,6 +337,22 @@ async def lifespan(app: FastAPI):
if plugin and plugin.manifest.permissions: if plugin and plugin.manifest.permissions:
register_plugin_permissions(record.name, 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) init_permission_registry(active_plugin_names)
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names)) logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
@@ -346,7 +375,7 @@ async def lifespan(app: FastAPI):
plugin = registry.get_plugin(name) plugin = registry.get_plugin(name)
if plugin: if plugin:
for entity_type, model_class in plugin.get_entity_models().items(): 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)) logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
# Register field definitions from active plugins only # Register field definitions from active plugins only
@@ -436,7 +465,6 @@ def create_app() -> FastAPI:
{"name": "entity-history", "description": "Audit trail and entity change history."}, {"name": "entity-history", "description": "Audit trail and entity change history."},
{"name": "import-export", "description": "Bulk import and export of contacts and data."}, {"name": "import-export", "description": "Bulk import and export of contacts and data."},
{"name": "plugins", "description": "Plugin management: list, install, activate, deactivate."}, {"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": "workflows", "description": "Workflow definitions, instances, and execution."},
{"name": "user-preferences", "description": "Per-user preference settings."}, {"name": "user-preferences", "description": "Per-user preference settings."},
{"name": "currencies", "description": "Currency management for multi-currency support."}, {"name": "currencies", "description": "Currency management for multi-currency support."},
@@ -474,6 +502,7 @@ def create_app() -> FastAPI:
) )
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(AuditMiddleware)
app.add_middleware(GeneralRateLimitMiddleware) app.add_middleware(GeneralRateLimitMiddleware)
app.add_middleware(RequestLoggingMiddleware) app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CircuitBreakerMiddleware) app.add_middleware(CircuitBreakerMiddleware)
@@ -516,6 +545,21 @@ def create_app() -> FastAPI:
504: "service_timeout", 504: "service_timeout",
} }
code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error") code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error")
# Structured detail passthrough (AGENTS.md): when a route raises
# HTTPException with a dict detail containing a machine-readable ``code``,
# preserve the structured shape instead of stringifying it.
raw_detail = exc.detail
if isinstance(raw_detail, dict):
inner_code = raw_detail.get("code", code)
body = build_error_response(
code=inner_code if inner_code in ERROR_CODES else code,
detail=raw_detail.get("detail") or str(raw_detail),
trace_id=trace_id,
)
# Preserve the full structured detail as a nested object so clients
# can read ``resp.json()["detail"]["code"]``.
body["detail"] = raw_detail
else:
body = build_error_response( body = build_error_response(
code=code, code=code,
detail=str(exc.detail) if exc.detail else None, detail=str(exc.detail) if exc.detail else None,
@@ -544,13 +588,12 @@ def create_app() -> FastAPI:
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(tenants.router) app.include_router(tenants.router)
app.include_router(notifications.router) app.include_router(notifications.router)
from app.routes.companies import router as companies_router # NOTE: contacts/companies/contact-folders routes are plugin-owned now
app.include_router(companies_router) # (Block B1) and mounted via the manifest.routes mechanism below with
app.include_router(contacts.router) # require_active_plugin("contacts") protection.
app.include_router(contact_folders.router)
app.include_router(contact_folder_permissions.router)
app.include_router(entity_permissions.router) app.include_router(entity_permissions.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(dashboards.router)
app.include_router(entity_history.router) app.include_router(entity_history.router)
app.include_router(import_export.router) app.include_router(import_export.router)
app.include_router(plugins.router) app.include_router(plugins.router)
@@ -569,7 +612,6 @@ def create_app() -> FastAPI:
app.include_router(compliance.router) app.include_router(compliance.router)
app.include_router(owner_transfer.router) app.include_router(owner_transfer.router)
app.include_router(custom_field_definitions.router) app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router) app.include_router(saved_filters.router)
app.include_router(saved_views.router) app.include_router(saved_views.router)
app.include_router(webhooks.router) app.include_router(webhooks.router)
@@ -582,6 +624,7 @@ def create_app() -> FastAPI:
app.include_router(outbox.router) app.include_router(outbox.router)
app.include_router(api_tokens.router) app.include_router(api_tokens.router)
app.include_router(approvals.router) app.include_router(approvals.router)
app.include_router(miniapps.router)
# ── Register plugin routes for all discovered plugins ── # ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete. # Routes are registered at app creation time so OpenAPI docs are complete.
+23 -2
View File
@@ -6,18 +6,24 @@ from app.models.audit import AuditLog
from app.models.auth import ApiToken, PasswordResetToken from app.models.auth import ApiToken, PasswordResetToken
from app.models.backup import Backup from app.models.backup import Backup
from app.models.bank_account import BankAccount from app.models.bank_account import BankAccount
from app.models.consumer_inbox import ConsumerInbox
from app.models.compliance import ComplianceIncident from app.models.compliance import ComplianceIncident
from app.models.contact import Contact, ContactPerson from app.models.consumer_inbox import ConsumerInbox
# 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_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory from app.models.contact_merge import ContactMergeHistory
from app.models.currency import Currency from app.models.currency import Currency
from app.models.custom_field_definition import CustomFieldDefinition 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_history import EntityHistory
from app.models.entity_permission import EntityPermission from app.models.entity_permission import EntityPermission
from app.models.entity_policy import EntityPolicy from app.models.entity_policy import EntityPolicy
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType 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.owned_mixin import OwnedMixin
from app.models.permission_delegation import PermissionDelegation from app.models.permission_delegation import PermissionDelegation
from app.models.permission_template import PermissionTemplate from app.models.permission_template import PermissionTemplate
@@ -75,6 +81,7 @@ __all__ = [
"WorkflowInstance", "WorkflowInstance",
"WorkflowStepHistory", "WorkflowStepHistory",
"SavedView", "SavedView",
"Dashboard",
] ]
from app.models.entity_attachment import EntityAttachment # noqa: F401 from app.models.entity_attachment import EntityAttachment # noqa: F401
from app.models.workspace import ( # noqa: F401 from app.models.workspace import ( # noqa: F401
@@ -83,3 +90,17 @@ from app.models.workspace import ( # noqa: F401
WorkspaceUser, WorkspaceUser,
WorkspaceWidget, 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)
+1 -2
View File
@@ -11,13 +11,12 @@ from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
# Re-export EntityHistory as DeletionLog for backward compatibility. # Re-export EntityHistory as DeletionLog for backward compatibility.
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute. # Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
+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 The physical home of Contact/ContactPerson moved to the ContactsPlugin:
('company' or 'person'). ContactPerson is a 1:N child for app/plugins.builtins.contacts.models
ansprechpartner (company employees / contact persons).
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 from __future__ import annotations
import uuid _EXPORTS = {"Contact", "ContactPerson"}
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): def __getattr__(name: str):
"""Unified contact entity — can be a company or a person. if name in _EXPORTS:
from app.plugins.builtins.contacts.models import Contact, ContactPerson
type='company': name is the company name, firstname/surname empty. return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
type='person': firstname/surname are the person's name, name empty. raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
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): def __dir__() -> list[str]:
"""Ansprechpartner — 1:N child of a Contact. return sorted(_EXPORTS | {"__getattr__", "__dir__"})
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
+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
)
+47 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -77,3 +77,49 @@ class EventOutbox(Base):
failed_at: Mapped[datetime | None] = mapped_column( failed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, DateTime(timezone=True), nullable=True,
) )
class OutboxDelivery(Base):
"""Per-consumer delivery status for an event_outbox row (Migration 0075).
Tracks whether each consumer successfully processed an event; an event is
only 'published' when all mandatory deliveries succeed.
"""
__tablename__ = "outbox_deliveries"
__table_args__ = (
UniqueConstraint(
"event_id", "consumer_name",
name="uq_outbox_deliveries_event_consumer",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid(),
)
event_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("event_outbox.id", ondelete="CASCADE"),
nullable=False,
)
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
status: Mapped[str] = mapped_column(
String(30), nullable=False, server_default="pending",
)
attempt_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0",
)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
+3
View File
@@ -53,6 +53,9 @@ class SystemSettings(Base, TenantMixin, OwnedMixin):
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem") theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
# Backup configuration # Backup configuration
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
backup_interval: Mapped[str] = mapped_column(String(20), nullable=False, default="daily", server_default="daily")
backup_retention_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7, server_default="7")
backup_destination: Mapped[str] = mapped_column(String(20), nullable=False, default="local", server_default="local")
# Automation plugin settings (JSONB) # Automation plugin settings (JSONB)
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Retention policy overrides (JSONB) — compliance module # Retention policy overrides (JSONB) — compliance module
-1
View File
@@ -12,7 +12,6 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, SoftDeleteMixin, TimestampMixin from app.core.db import Base, SoftDeleteMixin, TimestampMixin
from app.models.owned_mixin import OwnedMixin
class User(Base, TimestampMixin, SoftDeleteMixin): class User(Base, TimestampMixin, SoftDeleteMixin):
+89 -6
View File
@@ -53,15 +53,16 @@ class BasePlugin(ABC):
) -> None: ) -> None:
"""Called when the plugin is activated. """Called when the plugin is activated.
Override to register event listeners and prepare runtime state. Default implementation subscribes to events listed in the manifest and
Default implementation subscribes to events listed in the manifest. 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: self._register_manifest_events(event_bus)
handler = self._make_event_handler(event_name)
self._event_handlers[event_name] = handler
event_bus.subscribe(event_name, handler)
self._container = service_container self._container = service_container
self._register_manifest_miniapps()
async def on_deactivate( async def on_deactivate(
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
) -> None: ) -> None:
@@ -80,6 +81,57 @@ class BasePlugin(ABC):
get_hook_registry().unregister_all_for_plugin(self.manifest.name) 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: async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
"""Called when the plugin is uninstalled (before data tables are dropped). """Called when the plugin is uninstalled (before data tables are dropped).
@@ -97,6 +149,37 @@ class BasePlugin(ABC):
""" """
return [] return []
async def register_event_handlers(self, event_bus: EventBus) -> None:
"""Register event handlers for the background worker (ARCH-038 hook).
The worker calls this on every active plugin at startup so plugins
can subscribe to events even when the web process is separate.
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.
"""
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 ─── # ─── Job Modules ───
def get_job_modules(self) -> list[str]: def get_job_modules(self) -> list[str]:
@@ -12,7 +12,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from pgvector.sqlalchemy import Vector
class AgentMemory(Base, TenantMixin, OwnedMixin): class AgentMemory(Base, TenantMixin, OwnedMixin):
+26 -1
View File
@@ -3,7 +3,12 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin 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): class AgentMemoryPlugin(BasePlugin):
@@ -28,6 +33,26 @@ class AgentMemoryPlugin(BasePlugin):
"agent_memory:read", "agent_memory:read",
"agent_memory:write", "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, is_core=True,
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
@@ -20,12 +20,56 @@ _openapi_cache: dict[str, Any] | None = None
def _get_base_url() -> str: 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 import os
override = os.environ.get("INTERNAL_API_URL")
if override:
return override.rstrip("/")
port = os.environ.get("PORT", "8000") port = os.environ.get("PORT", "8000")
return f"http://127.0.0.1:{port}" 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]: async def get_openapi_spec() -> dict[str, Any]:
"""Get the CRM OpenAPI spec, cached.""" """Get the CRM OpenAPI spec, cached."""
global _openapi_cache global _openapi_cache
@@ -101,34 +145,15 @@ async def call_crm_api_handler(arguments: dict[str, Any], context: dict[str, Any
path = "/" + path path = "/" + path
try: try:
base_url = _get_base_url()
# Get user context for auth # Get user context for auth
tenant_id = context.get("tenant_id", "") tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "") user_id = context.get("user_id", "")
# Create a DB session to resolve a valid session token for this user # F09 (Astra P1): authenticated request via short-lived delegation
# We'll use internal service-level auth bypass # token - the acting user's real permissions apply.
headers = { resp = await _make_internal_api_request(
"Content-Type": "application/json", method, path, tenant_id=tenant_id, user_id=user_id, body=body
"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}"})
# Return response body (truncated if too large) # Return response body (truncated if too large)
try: try:
@@ -16,12 +16,13 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context from app.core.db import get_db, get_session_factory, set_tenant_context
from app.deps import get_current_user_bearer, require_permission from app.deps import get_current_user_bearer, require_permission_or_bearer
from app.plugins.builtins.ai_assistant.schemas import ( from app.plugins.builtins.ai_assistant.schemas import (
ExternalAgentRequest, ExternalAgentRequest,
ExternalAgentResponse, ExternalAgentResponse,
) )
from app.plugins.builtins.ai_assistant.services import stream_chat_comm as stream_chat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,7 +39,7 @@ async def _check_external_rate_limit(request: Request, tenant_id: str, token_pre
@router.post( @router.post(
"/{agent_id}/run", "/{agent_id}/run",
dependencies=[Depends(require_permission("ai:write"))], dependencies=[Depends(require_permission_or_bearer("ai:write"))],
) )
async def run_agent_external( async def run_agent_external(
agent_id: str, agent_id: str,
@@ -120,13 +121,19 @@ async def run_agent_external(
} }
# Run the agent via streaming chat (non-streaming mode) # Run the agent via streaming chat (non-streaming mode)
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
full_response = "" 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) await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat( async for chunk in stream_chat(
stream_db, session, agent, data.message, user_context, tenant_id stream_db,
session.id,
agent,
data.message,
user_context,
tenant_id,
uuid.UUID(current_user["user_id"]),
): ):
if chunk.startswith("data: ") and chunk != "data: [DONE]\n\n": if chunk.startswith("data: ") and chunk != "data: [DONE]\n\n":
try: try:
@@ -149,7 +156,7 @@ async def run_agent_external(
@router.get( @router.get(
"/{agent_id}/status", "/{agent_id}/status",
dependencies=[Depends(require_permission("ai:read"))], dependencies=[Depends(require_permission_or_bearer("ai:read"))],
) )
async def get_agent_status_external( async def get_agent_status_external(
agent_id: str, agent_id: str,
@@ -212,7 +219,7 @@ async def get_agent_status_external(
@router.post( @router.post(
"/{agent_id}/stream", "/{agent_id}/stream",
dependencies=[Depends(require_permission("ai:write"))], dependencies=[Depends(require_permission_or_bearer("ai:write"))],
) )
async def stream_agent_external( async def stream_agent_external(
agent_id: str, agent_id: str,
@@ -1,4 +1,7 @@
-- AI Assistant plugin initial migration -- 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 ( CREATE TABLE IF NOT EXISTS ai_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -68,35 +71,3 @@ CREATE TABLE IF NOT EXISTS ai_agents (
deleted_at TIMESTAMPTZ deleted_at TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id); 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 ( CREATE TABLE IF NOT EXISTS ai_chat_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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_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_tenant ON ai_chat_folders(tenant_id);
CREATE INDEX IF NOT EXISTS ix_ai_folders_parent ON ai_chat_folders(parent_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 -- 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; 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); CREATE INDEX IF NOT EXISTS ix_ai_folders_sort ON ai_chat_folders(sort_order);
@@ -0,0 +1,10 @@
-- Dual-path convergence (Gate B): add compliance columns that Alembic
-- migration 0119 adds on the core path. Idempotent so both install paths
-- converge to the identical schema.
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS region VARCHAR(20) NOT NULL DEFAULT 'unknown';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS hosting_type VARCHAR(30) NOT NULL DEFAULT 'cloud';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS dpa_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS retention_policy TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS training_on_customer_data BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS transfer_notice TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS allowed_data_classes JSONB NOT NULL DEFAULT '[]'::jsonb;
+4 -3
View File
@@ -42,7 +42,7 @@ class AIAssistantPlugin(BasePlugin):
), ),
], ],
events=[], events=[],
migrations=["0001_initial.sql", "0002_folders_attachments.sql"], migrations=["0001_initial.sql", "0002_folders_attachments.sql", "0003_sort_order.sql", "0004_compliance_fields.sql"],
permissions=[ permissions=[
"ai:read", "ai:read",
"ai:write", "ai:write",
@@ -52,13 +52,14 @@ class AIAssistantPlugin(BasePlugin):
], ],
is_core=True, is_core=True,
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90), FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90, permission='ai:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True), FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True, permission='ai:read'),
], ],
settings_pages=[ settings_pages=[
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60), 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", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
+14 -1
View File
@@ -234,6 +234,10 @@ async def stream_chat_comm(
tools.append(crm_api_tool) tools.append(crm_api_tool)
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None 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 # Build LLM params
params, model_id = await build_litellm_params(db, agent, messages, tenant_id) params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
@@ -290,8 +294,17 @@ async def stream_chat_comm(
except json.JSONDecodeError: except json.JSONDecodeError:
tool_args = {} 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) 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" result = f"Tool '{tool_name}' not found"
else: else:
result = await execute_tool_call(tool, tool_args, user_context) result = await execute_tool_call(tool, tool_args, user_context)
@@ -165,8 +165,8 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
db, tenant_id, _ = await _get_db_and_tenant(context) db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"] entity_type = arguments["entity_type"]
@@ -69,11 +69,13 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
# Post suggestion to Communication (I-WORK-PROACTIVE) # Post suggestion to Communication (I-WORK-PROACTIVE)
try: try:
import uuid as uuid_mod import uuid as uuid_mod
from sqlalchemy import select as sa_select
from app.core.db import get_worker_session_factory
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.models import CommConversation from app.plugins.builtins.kommunikation.models import CommConversation
from sqlalchemy import select as sa_select komm = get_contract_registry().get_contract("kommunikation")
from app.core.db import get_worker_session_factory
komm = get_contract_registry().get("kommunikation")
if komm: if komm:
factory = get_worker_session_factory() factory = get_worker_session_factory()
async with factory() as db: async with factory() as db:
@@ -274,8 +276,8 @@ async def gather_context(
# Upcoming calendar events # Upcoming calendar events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC) now = datetime.now(UTC)
event_result = await db.execute( event_result = await db.execute(
@@ -389,8 +391,8 @@ async def gather_context(
# Upcoming events # Upcoming events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC) now = datetime.now(UTC)
event_result = await db.execute( event_result = await db.execute(
+4 -1
View File
@@ -62,7 +62,10 @@ class AIUIControlPlugin(BasePlugin):
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name) get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus) # Remove the WebSocket manager BEFORE super() so that event handlers
# being unsubscribed can no longer reach it (ARCH-044).
if service_container.has("ai_ui_control_ws"): if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws") service_container.remove("ai_ui_control_ws")
logger.info("AI UI Control WebSocket manager removed") logger.info("AI UI Control WebSocket manager removed")
await super().on_deactivate(db, service_container, event_bus)
@@ -55,7 +55,8 @@ async def send_agent_message(
# 2. Create a kommunikation message in a dedicated agent room # 2. Create a kommunikation message in a dedicated agent room
try: try:
from app.plugins.builtins.kommunikation.contracts import CommConversation as Room, CommMessage as Message from app.plugins.builtins.kommunikation.contracts import CommConversation as Room
from app.plugins.builtins.kommunikation.contracts import CommMessage as Message
# Find or create the agent-to-agent room # Find or create the agent-to-agent room
room_name = f"agent:{from_agent_id}:{target_agent.id}" room_name = f"agent:{from_agent_id}:{target_agent.id}"
@@ -15,7 +15,7 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db 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 ( from app.plugins.builtins.automation.models import (
AgentDefinition, AgentDefinition,
AgentRun, AgentRun,
@@ -118,8 +118,13 @@ async def list_agents(
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False) 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, db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
user_id=user_id, is_system_admin=is_system_admin, 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( return AgentDefinitionListResponse(
items=[_agent_to_response(a) for a in items], items=[_agent_to_response(a) for a in items],
total=total, total=total,
@@ -166,14 +179,14 @@ async def list_tools(
) )
registry = get_tool_registry() registry = get_tool_registry()
tools = registry.list_tools() tools = registry.list_for_api()
return { return {
"items": [ "items": [
{ {
"id": t.get("id", t.get("name", "")), "id": t.get("name", ""),
"name": t.get("name", ""), "name": t.get("name", ""),
"description": t.get("description", ""), "description": t.get("description", ""),
"plugin": t.get("plugin", ""), "plugin": t.get("plugin_name", ""),
} }
for t in tools for t in tools
], ],
@@ -619,6 +632,7 @@ async def stream_agent_run(
in real-time as the agent processes. in real-time as the agent processes.
""" """
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.ai.agent_stream import stream_react_loop from app.ai.agent_stream import stream_react_loop
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
@@ -650,6 +664,7 @@ async def stream_agent_run(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
agent_run_id=aid, agent_run_id=aid,
user_permissions=current_user, # F01: enforce allowlist+permission at execution time
), ),
media_type="text/event-stream", media_type="text/event-stream",
) )
+27 -25
View File
@@ -11,7 +11,8 @@ Safety features:
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC, datetime import uuid
from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -37,12 +38,12 @@ async def run_agent(
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop) 3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
4. Budget limit: cumulative cost_usd 4. Budget limit: cumulative cost_usd
""" """
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.automation.models import ( from app.plugins.builtins.automation.models import (
AgentDefinition, AgentDefinition,
AgentRun, AgentRun,
AgentRunStep, AgentRunStep,
) )
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
factory = get_session_factory() factory = get_session_factory()
@@ -64,7 +65,7 @@ async def run_agent(
# ── Safety Check 1: Rate Limit ── # ── Safety Check 1: Rate Limit ──
if agent.max_executions_per_hour: if agent.max_executions_per_hour:
async with factory() as db: 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( count_result = await db.execute(
select(func.count()) select(func.count())
.select_from(AgentRun) .select_from(AgentRun)
@@ -110,9 +111,9 @@ async def run_agent(
try: try:
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
mail_contract = get_contract("mail") mail_contract = get_contract("mail")
if mail_contract and hasattr(mail_contract, "get_recent_mails"): if mail_contract and hasattr(mail_contract, "Mail"):
from sqlalchemy import select as _select from sqlalchemy import select as _select
from app.plugins.builtins.mail.models import Mail Mail = mail_contract.Mail # noqa: N806 — class alias
async with factory() as db: async with factory() as db:
mail_q = await db.execute( mail_q = await db.execute(
_select(Mail) _select(Mail)
@@ -167,7 +168,7 @@ async def run_agent(
perm_ctx = await resolve_agent_permissions( perm_ctx = await resolve_agent_permissions(
db=db, db=db,
tenant_id=agent.tenant_id, tenant_id=agent.tenant_id,
user_id=agent.created_by or uuid_mod.uuid4(), user_id=agent.created_by or uuid.uuid4(),
agent_definition=agent, agent_definition=agent,
) )
@@ -237,9 +238,18 @@ async def run_agent(
) )
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ── # ── 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 from app.ai.data_policy import enforce_data_policy
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( messages = await enforce_data_policy(
db=None, db=_dp_db,
tenant_id=agent.tenant_id, tenant_id=agent.tenant_id,
messages=messages, messages=messages,
agent_definition=agent, agent_definition=agent,
@@ -259,6 +269,7 @@ async def run_agent(
timeout_seconds=max_duration, timeout_seconds=max_duration,
require_approval=bool(getattr(agent, "require_approval", False)), require_approval=bool(getattr(agent, "require_approval", False)),
approval_tools=getattr(agent, "approval_tools", None), 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 timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
) )
@@ -376,26 +387,19 @@ async def run_agent(
# ── Post agent result to Communication (F-COMM) ── # ── Post agent result to Communication (F-COMM) ──
try: try:
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
komm = get_contract_registry().get("kommunikation") komm = get_contract_registry().get_contract("kommunikation")
if komm: if komm:
async with factory() as db: async with factory() as db:
# Find or create agent conversation room # Find or create agent conversation room via contract
from app.plugins.builtins.contracts import get_contract as _get_contract # (find_locked_room_id matches create_plugin_room semantics)
_komm_contract = _get_contract("kommunikation")
from app.plugins.builtins.kommunikation.models import CommConversation
from sqlalchemy import select as sa_select
room_title = f"Agent: {agent.name}" room_title = f"Agent: {agent.name}"
existing = await db.execute( conv_id = await komm.find_locked_room_id(
sa_select(CommConversation).where( db=db,
CommConversation.tenant_id == agent.tenant_id, tenant_id=agent.tenant_id,
CommConversation.title == room_title, plugin_name="automation",
CommConversation.is_locked.is_(True), title=room_title,
CommConversation.locked_by == "automation",
CommConversation.deleted_at.is_(None),
) )
) if not conv_id:
conv = existing.scalar_one_or_none()
if not conv:
room = await komm.create_plugin_room( room = await komm.create_plugin_room(
db=db, db=db,
tenant_id=agent.tenant_id, tenant_id=agent.tenant_id,
@@ -405,8 +409,6 @@ async def run_agent(
participant_type="agent", participant_type="agent",
) )
conv_id = uuid.UUID(room["conversation_id"]) conv_id = uuid.UUID(room["conversation_id"])
else:
conv_id = conv.id
# Post result as message with action_card block # Post result as message with action_card block
status = result_data.get("status", "unknown") status = result_data.get("status", "unknown")
@@ -63,6 +63,31 @@ class AutomationContract:
# ─── agent_comm ─── # ─── agent_comm ───
send_agent_message = staticmethod(send_agent_message) 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 @classmethod
def get_function(cls, name: str): def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent.""" """Return a callable exposed by this contract, or None if absent."""
@@ -0,0 +1,31 @@
-- Dual-path convergence (Gate B): create the ReAct step-tracking table
-- that Alembic migration 0121 creates on the core path, add the Phase-F
-- columns from 0122, and apply the RLS policy from 0129/0136. Idempotent
-- so both install paths converge to the identical schema.
CREATE TABLE IF NOT EXISTS automation_agent_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
agent_run_id UUID NOT NULL REFERENCES automation_agent_runs(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
thought TEXT,
action VARCHAR(255),
action_input JSONB,
observation TEXT,
cost_usd FLOAT NOT NULL DEFAULT 0.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_agent_run_steps_run ON automation_agent_run_steps(tenant_id, agent_run_id);
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS temperature FLOAT NOT NULL DEFAULT 0.3;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 1000;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_steps INTEGER NOT NULL DEFAULT 20;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trace_mode VARCHAR(20) NOT NULL DEFAULT 'standard';
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS skill_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS ai_use_case_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
-- RLS matching migrations 0129 + 0136 (current_tenant_id variant)
ALTER TABLE automation_agent_run_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON automation_agent_run_steps;
CREATE POLICY tenant_isolation ON automation_agent_run_steps
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
+77 -45
View File
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
FrontendSettingsPage, FrontendSettingsPage,
MiniAppContribution,
PluginManifest, PluginManifest,
PluginRouteDef, PluginRouteDef,
) )
@@ -38,7 +39,7 @@ class AutomationPlugin(BasePlugin):
"Define AI agents with LLM models and tools, create event/schedule/manual " "Define AI agents with LLM models and tools, create event/schedule/manual "
"automations with conditions and actions, schedule cron jobs, and track execution logs." "automations with conditions and actions, schedule cron jobs, and track execution logs."
), ),
dependencies=[], dependencies=["mail"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/automation", path="/api/v1/automation",
@@ -62,7 +63,26 @@ class AutomationPlugin(BasePlugin):
"mail.received", "mail.received",
"workflow.timeout", "workflow.timeout",
], ],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"], 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=[ permissions=[
"automation:read", "automation:read",
"automation:write", "automation:write",
@@ -82,6 +102,7 @@ class AutomationPlugin(BasePlugin):
path="/workflows", path="/workflows",
icon="Workflow", icon="Workflow",
order=52, order=52,
permission="workflows:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.importExport", label_key="nav.importExport",
@@ -89,6 +110,16 @@ class AutomationPlugin(BasePlugin):
path="/import-export", path="/import-export",
icon="ArrowUpDown", icon="ArrowUpDown",
order=53, 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( FrontendMenuItem(
label_key="nav.dedupMerge", label_key="nav.dedupMerge",
@@ -96,6 +127,7 @@ class AutomationPlugin(BasePlugin):
path="/contacts/dedup", path="/contacts/dedup",
icon="Copy", icon="Copy",
order=54, order=54,
permission="contacts:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.tags", label_key="nav.tags",
@@ -103,6 +135,7 @@ class AutomationPlugin(BasePlugin):
path="/tags", path="/tags",
icon="Tag", icon="Tag",
order=55, order=55,
permission="tags:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.activity", label_key="nav.activity",
@@ -110,28 +143,32 @@ class AutomationPlugin(BasePlugin):
path="/activity", path="/activity",
icon="Activity", icon="Activity",
order=56, order=56,
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=[ page_routes=[
FrontendPageRoute(
path="/automation",
component="@/pages/AutomationDashboard",
order=50,
),
FrontendPageRoute(
path="/agents",
component="@/pages/AgentDashboard",
order=51,
),
FrontendPageRoute( FrontendPageRoute(
path="/workflows", path="/workflows",
component="@/pages/Workflows", component="@/pages/Workflows",
order=52, order=52,
permission="workflows:read",
), ),
FrontendPageRoute( FrontendPageRoute(
path="/import-export", path="/import-export",
component="@/pages/ImportExport", component="@/pages/ImportExport",
order=53, 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=[ settings_pages=[
@@ -215,22 +252,10 @@ class AutomationPlugin(BasePlugin):
self._register_workflow_agent_tools() self._register_workflow_agent_tools()
except Exception: except Exception:
logger.exception("Failed to register workflow agent tools") logger.exception("Failed to register workflow agent tools")
# Register MiniApps from manifest # NOTE: Manifest MiniApps are registered by super().on_activate()
try: # (BasePlugin, Phase M1) WITH all fields (permission, component,
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry # settings_schema). The legacy re-registration here dropped those
registry = get_miniapp_registry() # fields and overwrote the correct entries — removed (M5 fix).
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")
# Register own cron jobs from manifest # Register own cron jobs from manifest
try: try:
await self.register_plugin_contributions(db, self.manifest.name, self.manifest) await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
@@ -239,18 +264,25 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to register own cron jobs") logger.exception("Failed to register own cron jobs")
# Register pre-built agents in DB (if not already present) # Register pre-built agents in DB (if not already present)
try: try:
from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.builtins.automation.prebuilt.email_triage_agent import create_email_triage_agent
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import create_contact_enrichment_agent
from app.plugins.builtins.automation.prebuilt.follow_up_agent import create_follow_up_agent
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
# Get first tenant + admin user for seeding # Get system tenant + admin user for seeding (ARCH-043:
from app.models.user import User # deterministic slug lookup instead of arbitrary first row)
from app.models.tenant import Tenant from app.core.db import get_system_tenant
tenant_result = await db.execute(sa_select(Tenant).limit(1)) from app.models.user import User, UserTenant
tenant = tenant_result.scalar_one_or_none() from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import (
create_contact_enrichment_agent,
)
from app.plugins.builtins.automation.prebuilt.email_triage_agent import (
create_email_triage_agent,
)
from app.plugins.builtins.automation.prebuilt.follow_up_agent import (
create_follow_up_agent,
)
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
tenant = await get_system_tenant(db)
if tenant: if tenant:
user_result = await db.execute( user_result = await db.execute(
sa_select(User) sa_select(User)
@@ -288,15 +320,14 @@ class AutomationPlugin(BasePlugin):
def _register_workflow_agent_tools(self) -> None: def _register_workflow_agent_tools(self) -> None:
"""Register I-AW agent tools for starting and inspecting workflows.""" """Register I-AW agent tools for starting and inspecting workflows."""
import uuid import uuid
from typing import Any
from app.ai.tool_registry import get_tool_registry from app.ai.tool_registry import get_tool_registry
registry = get_tool_registry() registry = get_tool_registry()
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Start a workflow by ID.""" """Start a workflow by ID."""
from app.services.workflow_service import create_instance
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.services.workflow_service import create_instance
workflow_id = arguments.get("workflow_id", "") workflow_id = arguments.get("workflow_id", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
user_id = context.get("user_id") user_id = context.get("user_id")
@@ -332,8 +363,9 @@ class AutomationPlugin(BasePlugin):
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Check the status of a workflow instance.""" """Check the status of a workflow instance."""
from sqlalchemy import select from sqlalchemy import select
from app.models.workflow import WorkflowInstance
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.workflow import WorkflowInstance
instance_id = arguments.get("instance_id", "") instance_id = arguments.get("instance_id", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
if not instance_id or not tenant_id: if not instance_id or not tenant_id:
@@ -418,16 +450,16 @@ class AutomationPlugin(BasePlugin):
from another plugin's manifest. Uses plugin name prefixing for conflict resolution.""" from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
from sqlalchemy import select from sqlalchemy import select
# Get default tenant_id from the first tenant in the DB # Get system tenant for contributions (ARCH-043: deterministic slug
from app.models.tenant import Tenant # lookup instead of arbitrary first row)
from app.core.db import get_system_tenant
from app.plugins.builtins.automation.models import AutomationCronJob from app.plugins.builtins.automation.models import AutomationCronJob
from app.plugins.builtins.automation.services import ( from app.plugins.builtins.automation.services import (
AgentService, AgentService,
AutomationService, AutomationService,
CronJobService, CronJobService,
) )
tenant_result = await db.execute(select(Tenant).limit(1)) tenant = await get_system_tenant(db)
tenant = tenant_result.scalar_one_or_none()
default_tenant_id = tenant.id if tenant else None default_tenant_id = tenant.id if tenant else None
if default_tenant_id is None: if default_tenant_id is None:
logger.warning("No tenant found — skipping plugin contributions registration") logger.warning("No tenant found — skipping plugin contributions registration")
@@ -3,7 +3,9 @@
Enriches contact data by searching for related information. Enriches contact data by searching for related information.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system. CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system.
@@ -3,7 +3,9 @@
Sorts and prioritizes incoming emails automatically. Sorts and prioritizes incoming emails automatically.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system. EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system.
@@ -3,7 +3,9 @@
Reminds about and creates follow-up tasks for contacts. Reminds about and creates follow-up tasks for contacts.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system. FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system.
@@ -3,7 +3,9 @@
Generates reports from CRM data using search and API tools. Generates reports from CRM data using search and API tools.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system. REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system.
@@ -1,60 +1,132 @@
"""Tests for the Automation & Agents plugin. """Tests for the Automation & Agents plugin.
Uses pytest with async fixtures. Tests use SQLite in-memory database Uses pytest with async fixtures against an ephemeral PostgreSQL database
since PostgreSQL may not be available in the dev container. (SQLITE-001 fix) matches the project convention and exercises the real
PGUUID/JSONB column types.
""" """
from __future__ import annotations from __future__ import annotations
# Register ALL plugin models so create_all can resolve cross-plugin FKs
# (e.g. entity_attachments.dms_file_id -> files) — same pattern as
# scripts/sync_plugin_schema.py.
import importlib
import os
import pkgutil
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — registers core models
import app.models.outbox # noqa: F401 — event_outbox is NOT re-exported by app.models
import app.plugins.builtins as _builtins_pkg
from app.core.db import Base from app.core.db import Base
from app.plugins.builtins.automation.models import (
for _importer, _modname, _ispkg in pkgutil.iter_modules(_builtins_pkg.__path__):
if not _ispkg:
continue
try:
importlib.import_module(f"app.plugins.builtins.{_modname}.models")
except ImportError:
pass # plugin without models module
except Exception: # pragma: no cover - defensive
pass
from app.plugins.builtins.automation.models import ( # noqa: E402 — after dynamic plugin-model discovery
AgentRun, AgentRun,
AutomationRun, AutomationRun,
) )
from app.plugins.builtins.automation.services import ( from app.plugins.builtins.automation.services import ( # noqa: E402 — after dynamic plugin-model discovery
AgentService, AgentService,
AutomationService, AutomationService,
CronJobService, CronJobService,
) )
def _ephemeral_db_url() -> str:
"""Derive an ephemeral test DB URL from DATABASE_URL/.env.test."""
base_url = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://leocrm_test:test123@localhost:5432/leocrm_test",
)
return f"{base_url.rsplit('/', 1)[0]}/automation_test_{uuid.uuid4().hex[:8]}"
# ─── Fixtures ─── # ─── Fixtures ───
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db() -> AsyncGenerator[AsyncSession, None]: async def db() -> AsyncGenerator[AsyncSession, None]:
"""Create an in-memory SQLite database for testing.""" """Create an ephemeral PostgreSQL database for this test run."""
engine = create_async_engine( db_url = _ephemeral_db_url()
"sqlite+aiosqlite:///:memory:", admin_url = db_url.rsplit("/", 1)[0] + "/postgres"
echo=False,
) from sqlalchemy.ext.asyncio import create_async_engine as _cae
admin_engine = _cae(admin_url, isolation_level="AUTOCOMMIT")
async with admin_engine.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{db_url.rsplit("/", 1)[1]}"'))
await admin_engine.dispose()
# Plugin models use the pgvector Vector type — enable the extension in
# the fresh database before create_all runs (must connect to the target
# DB itself; CREATE EXTENSION has no ON DATABASE clause).
ext_engine = _cae(db_url, isolation_level="AUTOCOMMIT")
async with ext_engine.connect() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await ext_engine.dispose()
engine = create_async_engine(db_url, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session: async with async_session() as session:
yield session yield session
finally:
await engine.dispose() await engine.dispose()
admin_engine2 = _cae(admin_url, isolation_level="AUTOCOMMIT")
async with admin_engine2.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{db_url.rsplit("/", 1)[1]}"'))
await admin_engine2.dispose()
@pytest.fixture @pytest_asyncio.fixture
def tenant_id() -> uuid.UUID: async def tenant_id(db: AsyncSession) -> uuid.UUID:
return uuid.uuid4() """Create a real tenant row — PostgreSQL enforces FKs, unlike SQLite."""
from app.models.tenant import Tenant
tid = uuid.uuid4()
db.add(Tenant(id=tid, name="Test Org", slug=f"test-{tid.hex[:8]}"))
await db.commit()
return tid
@pytest.fixture @pytest_asyncio.fixture
def user_id() -> uuid.UUID: async def user_id(db: AsyncSession, tenant_id: uuid.UUID) -> uuid.UUID:
return uuid.uuid4() """Create a real user row belonging to the test tenant."""
from app.models.user import User
uid = uuid.uuid4()
db.add(
User(
id=uid,
email=f"test-{uid.hex[:8]}@example.com",
name="Test User",
password_hash="not-a-real-hash",
is_active=True,
)
)
await db.commit()
return uid
# ─── AgentService Tests ─── # ─── AgentService Tests ───
@@ -425,11 +497,17 @@ class TestDryRunMode:
assert automation.dry_run is True assert automation.dry_run is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID): async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
"""Test that dry_run flag is stored in AutomationRun.""" """Test that dry_run flag is stored in AutomationRun."""
# PostgreSQL enforces the FK to automations — create a real one first
data = {"name": "dry-run-flag", "description": "", "trigger_type": "manual",
"trigger_config": {}, "conditions": [], "actions": [],
"is_active": True, "dry_run": True}
automation = await AutomationService.create(db, tenant_id, data, user_id=user_id)
run = AutomationRun( run = AutomationRun(
tenant_id=tenant_id, tenant_id=tenant_id,
automation_id=uuid.uuid4(), automation_id=automation.id,
status="dry_run", status="dry_run",
started_at=datetime.now(UTC), started_at=datetime.now(UTC),
dry_run=True, dry_run=True,
@@ -475,7 +553,9 @@ class TestRateLimiting:
) )
recent_runs = result.scalar() or 0 recent_runs = result.scalar() or 0
assert recent_runs == 2 assert recent_runs == 2
assert recent_runs < agent.max_executions_per_hour # 2 < 2 is False, so limit would be hit # With max_executions_per_hour=2 and 2 runs in the window, the limit
# is reached — the next execution must be blocked.
assert recent_runs >= agent.max_executions_per_hour
# ─── Budget Limit Tests ─── # ─── Budget Limit Tests ───
@@ -512,7 +592,8 @@ class TestBudgetLimit:
.where(AgentRun.agent_id == agent.id) .where(AgentRun.agent_id == agent.id)
) )
total_cost = float(cost_result.scalar() or 0.0) total_cost = float(cost_result.scalar() or 0.0)
assert total_cost == 0.6 # FLOAT column accumulates binary rounding (0.6000000000000001)
assert total_cost == pytest.approx(0.6)
assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded
@@ -2,6 +2,11 @@
from __future__ import annotations 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.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
@@ -15,6 +20,68 @@ class CalendarContract:
CalendarEntry = CalendarEntry CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink 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 @classmethod
def get_function(cls, name: str): def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent.""" """Return a callable exposed by this contract, or None if absent."""
+2 -3
View File
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
DateTime, DateTime,
@@ -13,14 +14,12 @@ from sqlalchemy import (
Index, Index,
String, String,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
from pgvector.sqlalchemy import Vector
class Calendar(Base, TenantMixin, OwnedMixin): class Calendar(Base, TenantMixin, OwnedMixin):
+20 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import ( from app.plugins.manifest import (
FrontendDetailTab, FrontendDashboardWidget,
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
PluginManifest, PluginManifest,
@@ -40,6 +40,18 @@ class CalendarPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"], migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
dashboard_widgets=[
FrontendDashboardWidget(
id="calendar_upcoming",
label_key="dashboard.calendarUpcoming",
label="Upcoming Appointments",
component="@/components/dashboard/CalendarUpcomingWidget",
icon="Calendar",
order=30,
col_span=1,
permission="calendar:read",
),
],
permissions=[ permissions=[
"calendar:read", "calendar:read",
"calendar:write", "calendar:write",
@@ -48,14 +60,16 @@ class CalendarPlugin(BasePlugin):
"calendar:admin", "calendar:admin",
], ],
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20), FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20, permission='calendar:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True), FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'),
], # Q1: kanban view was static-only before - now manifest-declared.
detail_tabs=[ FrontendPageRoute(path='/calendar/kanban', component='@/pages/CalendarKanban', protected=True, permission='calendar:read'),
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'),
], ],
# BUG (ghost component): ContactCalendarTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
hooks=["calendar.before_appointment", "calendar.after_appointment"], hooks=["calendar.before_appointment", "calendar.after_appointment"],
+29 -5
View File
@@ -21,8 +21,9 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db 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 ( from app.plugins.builtins.calendar.ics_utils import (
export_entries_to_ics, export_entries_to_ics,
ics_events_to_entry_data, ics_events_to_entry_data,
@@ -167,8 +168,13 @@ async def _check_write_permission(
async def list_calendars( async def list_calendars(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute( result = await db.execute(
select(Calendar).where( select(Calendar).where(
@@ -177,6 +183,13 @@ async def list_calendars(
) )
) )
cals = result.scalars().all() 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] return [_calendar_to_dict(c) for c in cals]
@@ -358,8 +371,13 @@ async def list_entries(
end: str | None = None, end: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer") role = current_user.get("role", "viewer")
@@ -369,6 +387,14 @@ async def list_entries(
CalendarEntry.deleted_at.is_(None), 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 # Filter private entries: only owner + admin can see
if role != "admin": if role != "admin":
query = query.where( query = query.where(
@@ -1023,5 +1049,3 @@ async def book_resource(
"start_at": booking.start_at.isoformat(), "start_at": booking.start_at.isoformat(),
"end_at": booking.end_at.isoformat(), "end_at": booking.end_at.isoformat(),
} }
from app.core.audit import log_audit
+475
View File
@@ -0,0 +1,475 @@
"""Public contract for the contacts plugin.
Exposes the symbols that other core modules and plugins need without
importing from internal modules directly (Block C7: dashboard counts).
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.models.contact import Contact
from app.plugins.builtins.contracts import get_contract_registry
class ContactsContract:
"""Public API surface for the contacts plugin."""
contract_name = "contacts"
@staticmethod
async def get_counts(
db: AsyncSession,
tenant_id: Any,
user_id: Any,
is_system_admin: bool = False,
) -> dict[str, int]:
"""Return visibility-filtered contact/company/person counts."""
queries = []
for type_filter in (None, "company", "person"):
query = select(func.count(Contact.id)).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if type_filter is not None:
query = query.where(Contact.type == type_filter)
query = await apply_visibility_filter(
db, query, "contact", Contact, user_id, tenant_id, is_system_admin
)
queries.append(query)
results = [((await db.execute(q)).scalar() or 0) for q in queries]
return {
"contacts": results[0],
"companies": results[1],
"persons": results[2],
"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."""
return getattr(cls, name, None)
# ─── self-registration ───
_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
+132 -6
View File
@@ -9,44 +9,170 @@ from __future__ import annotations
import logging import logging
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest from app.plugins.manifest import (
FieldDefinition,
FrontendDashboardWidget,
FrontendMenuItem,
FrontendPageRoute,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ContactsPlugin(BasePlugin): class ContactsPlugin(BasePlugin):
"""Contacts plugin — manages Contact entity lifecycle (models, permissions, restore, history). """Contacts plugin — owns the full Contact domain (Block B1).
Routes remain in app/routes/contacts.py as core routes, but entity lifecycle Routes (contacts, companies, contact folders, folder permissions) live in
(permissions, entity models, restore, history) is managed through on_activate/on_deactivate. this plugin and are mounted via manifest.routes with
require_active_plugin("contacts") protection. Entity lifecycle
(permissions, entity models, restore, history) is managed through
on_activate/on_deactivate like every other business plugin.
""" """
manifest = PluginManifest( manifest = PluginManifest(
name="contacts", name="contacts",
version="1.0.0", version="1.1.0",
display_name="Contacts", display_name="Contacts",
description="Core CRM contacts — persons and companies.", description="Core CRM contacts — persons and companies.",
dependencies=[], dependencies=[],
routes=[], # Routes are registered as core routes in main.py routes=[
PluginRouteDef(
path="/api/v1/contacts",
module="app.plugins.builtins.contacts.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/companies",
module="app.plugins.builtins.contacts.company_routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/contact-folders",
module="app.plugins.builtins.contacts.folder_routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/contact-folders",
module="app.plugins.builtins.contacts.folder_permission_routes",
router_attr="router",
),
],
events=[], events=[],
migrations=[], 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",
label_key="dashboard.recentContacts",
label="Recent Contacts",
component="@/components/dashboard/RecentContactsWidget",
icon="Users",
order=10,
col_span=2,
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=[ permissions=[
"contacts:read", "contacts:read",
"contacts:write", "contacts:write",
"contacts:delete", "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, is_core=True,
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
contract_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]: def get_entity_models(self) -> dict[str, type]:
from app.models.contact import Contact from app.models.contact import Contact
from app.models.contact_folder import ContactFolder
return { return {
"contact": Contact, "contact": Contact,
"contacts": Contact, "contacts": Contact,
"company": 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: async def on_activate(self, db, service_container, event_bus) -> None:
@@ -13,6 +13,7 @@ from typing import Any
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.contact_commands import ( 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.db import get_db
from app.core.visibility import check_single_entity_access 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 ( from app.schemas.contact import (
ContactCreate, ContactCreate,
ContactPersonCreate, ContactPersonCreate,
@@ -31,7 +35,6 @@ from app.schemas.contact import (
ContactUpdate, ContactUpdate,
) )
from app.services import contact_service, dedup_service from app.services import contact_service, dedup_service
from app.services.export_service import export_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) 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)"), cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), 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. """List contacts with pagination, FTS search, type/folder filter, sorting.
Supports keyset pagination via ``cursor`` parameter for large datasets. 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -84,6 +90,7 @@ async def list_contacts(
user_id=user_id, user_id=user_id,
is_system_admin=is_admin, is_system_admin=is_admin,
cursor=cursor, cursor=cursor,
workspace_scope=workspace_scope,
) )
@@ -95,14 +102,20 @@ async def export_contacts(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False) is_admin = current_user.get("is_system_admin", False)
csv_data = await export_service.export_contacts_csv( from app.plugins.builtins.contacts.contracts import ContactsContract
db, tenant_id, contact_type=type, search=search,
headers, rows = await ContactsContract.ie_fetch_rows(
db, tenant_id, "contacts",
user_id=user_id, is_system_admin=is_admin, 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( return StreamingResponse(
io.StringIO(csv_data), io.StringIO(csv_data),
media_type="text/csv", media_type="text/csv",
@@ -316,3 +329,189 @@ async def merge_duplicate_contacts(
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) raise HTTPException(status_code=400, detail=result.error)
return result.data 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}

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