Phase 0+3: Stand sichern, alte Doku einfrieren, doppelte Command-Struktur entfernen

Phase 0:
- Git Tag: pre-recovery-current (3cbf921)
- Branch: recovery/minimal-finish
- docs/RECOVERY_SCOPE.md als verbindliche Quelle
- Alte Dokumente als UEBERHOLT markiert

Phase 3:
- app/core/commands.py entfernt (ungenutzte Doppelstruktur)
- app/commands/create_contact.py entfernt (ungenutzte Doppelstruktur)
- 24/24 Command-Tests bestanden — produktive Commands unbeeinflusst
This commit is contained in:
Agent Zero
2026-08-03 13:25:48 +02:00
parent 3cbf92191e
commit a760a759eb
6 changed files with 68 additions and 306 deletions
+1
View File
@@ -1,3 +1,4 @@
ÜBERHOLT NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
# LeoCRM Sanierungsfortschritt
**Letztes Update:** 2026-08-03
-103
View File
@@ -1,103 +0,0 @@
"""Example command: CreateContact using the Command Pattern.
This is a reference implementation for new modules.
Existing contact_service.py is NOT changed — this is an alternative path.
Usage:
@router.post("/contacts-v2")
async def create_contact_v2(
body: CreateContactDTO,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
ctx = RequestContext(
user_id=uuid.UUID(current_user["user_id"]),
tenant_id=uuid.UUID(current_user["tenant_id"]),
is_system_admin=current_user.get("is_system_admin", False),
permissions=set(current_user.get("permissions", [])),
)
cmd = CreateContactCommand(
firstname=body.firstname,
surname=body.surname,
email=body.email,
)
handler = CreateContactHandler()
return await handler.execute(cmd, ctx, db)
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from app.core.commands import CommandHandler, RequestContext, UnitOfWork
from app.models.contact import Contact
@dataclass
class CreateContactCommand:
"""Command to create a new contact."""
firstname: str
surname: str
email: str | None = None
phone: str | None = None
company: str | None = None
class CreateContactHandler(CommandHandler[CreateContactCommand, dict[str, Any]]):
"""Handler for CreateContactCommand.
Demonstrates the Command Pattern:
1. Authorization check (ctx.require)
2. Domain operation (create Contact)
3. Outbox event (crm.contact.created.v1)
4. Audit log (contact.created)
5. Single commit via UoW
"""
async def handle(self, cmd: CreateContactCommand, ctx: RequestContext, uow: UnitOfWork) -> dict[str, Any]:
# 1. Authorization
ctx.require("contacts:write")
# 2. Domain operation
contact = Contact(
tenant_id=ctx.tenant_id,
firstname=cmd.firstname,
surname=cmd.surname,
email_1=cmd.email,
phone_1=cmd.phone,
company=cmd.company,
owner_id=ctx.user_id,
created_by=ctx.user_id,
updated_by=ctx.user_id,
)
uow.add(contact)
# 3. Outbox event (standardized envelope)
uow.outbox_add(
event_name="crm.contact.created.v1",
aggregate_id=contact.id, # Will be set after flush
aggregate_type="contact",
payload={
"firstname": cmd.firstname,
"surname": cmd.surname,
"email": cmd.email,
},
)
# 4. Audit log
uow.audit_record(
action="create",
entity_id=contact.id,
entity_type="contact",
changes={"firstname": cmd.firstname, "surname": cmd.surname, "email": cmd.email},
)
# 5. Return dict (will be populated after flush in commit)
return {
"id": str(contact.id),
"firstname": contact.firstname,
"surname": contact.surname,
"email_1": contact.email_1,
}
-203
View File
@@ -1,203 +0,0 @@
"""Command pattern infrastructure for new modules.
This provides a clean, transactional command handler pattern:
HTTP Route → Command Handler → Authorization → Domain Operation → Audit + Outbox → one Commit
Existing services are NOT refactored — they continue to work as-is.
New modules (ERP, etc.) should use this pattern.
Usage:
@dataclass
class CreateInvoiceCommand:
customer_id: uuid.UUID
amount: Decimal
class CreateInvoiceHandler(CommandHandler[CreateInvoiceCommand, Invoice]):
async def handle(self, cmd: CreateInvoiceCommand, ctx: RequestContext, uow: UnitOfWork) -> Invoice:
ctx.require("invoices:create")
invoice = Invoice.create(tenant_id=ctx.tenant_id, owner_id=ctx.user_id, ...)
uow.add(invoice)
uow.outbox.add("crm.invoice.created.v1", invoice.id, "invoice", invoice.to_dict())
uow.audit.record("invoice.created", invoice.id)
return invoice
# In route:
@router.post("/invoices")
async def create_invoice(body: CreateInvoiceDTO, ctx: RequestContext = Depends(get_request_context)):
cmd = CreateInvoiceCommand(customer_id=body.customer_id, amount=body.amount)
handler = CreateInvoiceHandler()
result = await handler.execute(cmd, ctx)
return result
"""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.outbox import enqueue_outbox_event
TCommand = TypeVar("TCommand")
TResult = TypeVar("TResult")
@dataclass
class RequestContext:
"""Request context with user, tenant, and permission info.
Passed to every command handler. Provides authorization checks.
"""
user_id: uuid.UUID
tenant_id: uuid.UUID
is_system_admin: bool = False
permissions: set[str] = field(default_factory=set)
correlation_id: uuid.UUID = field(default_factory=uuid.uuid4)
def require(self, permission: str) -> None:
"""Require a permission. Raises PermissionError if not granted."""
if self.is_system_admin:
return
if permission not in self.permissions:
raise PermissionError(f"Missing permission: {permission}")
def has(self, permission: str) -> bool:
"""Check if user has a permission."""
if self.is_system_admin:
return True
return permission in self.permissions
class UnitOfWork:
"""Unit of Work — collects changes, audit, and outbox events.
One UoW per business operation. Commit happens once at the end.
"""
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
self.db = db
self.tenant_id = tenant_id
self.user_id = user_id
self._audit_entries: list[dict[str, Any]] = []
self._outbox_events: list[dict[str, Any]] = []
def add(self, entity: Any) -> None:
"""Add an entity to the session."""
self.db.add(entity)
def outbox_add(
self,
event_name: str,
aggregate_id: uuid.UUID,
aggregate_type: str,
payload: dict[str, Any],
schema_version: int = 1,
) -> None:
"""Queue an outbox event for commit."""
self._outbox_events.append({
"event_name": event_name,
"aggregate_id": aggregate_id,
"aggregate_type": aggregate_type,
"payload": payload,
"schema_version": schema_version,
})
def audit_record(self, action: str, entity_id: uuid.UUID, entity_type: str = "", changes: dict[str, Any] | None = None) -> None:
"""Queue an audit log entry for commit."""
self._audit_entries.append({
"action": action,
"entity_id": entity_id,
"entity_type": entity_type,
"changes": changes or {},
})
async def commit(self) -> None:
"""Flush, write audit + outbox, then commit."""
# Flush to get entity IDs
await self.db.flush()
# Write outbox events
for evt in self._outbox_events:
await enqueue_outbox_event(
self.db,
self.tenant_id,
evt["event_name"],
evt["payload"],
aggregate_type=evt["aggregate_type"],
aggregate_id=evt["aggregate_id"],
schema_version=evt["schema_version"],
)
# Write audit entries
for entry in self._audit_entries:
await log_audit(
self.db,
self.tenant_id,
self.user_id,
entry["action"],
entry["entity_type"],
entry["entity_id"],
changes=entry["changes"],
)
# Single commit for everything
await self.db.commit()
async def rollback(self) -> None:
"""Rollback the transaction."""
await self.db.rollback()
class CommandHandler(ABC, Generic[TCommand, TResult]):
"""Base class for command handlers.
Subclasses implement `handle()` with the business logic.
The `execute()` method wraps it with UoW creation and error handling.
"""
@abstractmethod
async def handle(self, command: TCommand, ctx: RequestContext, uow: UnitOfWork) -> TResult:
"""Business logic. Use uow.add(), uow.outbox_add(), uow.audit_record()."""
...
async def execute(self, command: TCommand, ctx: RequestContext, db: AsyncSession) -> TResult:
"""Execute the command with a Unit of Work.
Creates a UoW, calls handle(), commits on success, rolls back on error.
"""
uow = UnitOfWork(db, ctx.tenant_id, ctx.user_id)
try:
result = await self.handle(command, ctx, uow)
await uow.commit()
return result
except Exception:
await uow.rollback()
raise
# ── FastAPI Dependency ───────────────────────────────────────────────────────
async def get_request_context(
current_user: dict = None, # Will be injected by FastAPI with require_permission
) -> RequestContext:
"""Build a RequestContext from the current user.
Usage in routes:
ctx: RequestContext = Depends(get_request_context)
"""
if current_user is None:
raise PermissionError("Not authenticated")
return RequestContext(
user_id=uuid.UUID(current_user["user_id"]),
tenant_id=uuid.UUID(current_user["tenant_id"]),
is_system_admin=current_user.get("is_system_admin", False),
permissions=set(current_user.get("permissions", [])),
)
+1
View File
@@ -1,3 +1,4 @@
ÜBERHOLT NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
# LeoCRM — Abschlussbericht Phase 0 + Phase 1 und vollständiger Sanierungsplan
**Datum:** 2026-08-01
+65
View File
@@ -0,0 +1,65 @@
# LeoCRM Recovery Scope
**Erstellt:** 2026-08-03
**Git-Tag:** `pre-recovery-current` (3cbf921)
**Branch:** `recovery/minimal-finish`
**Alembic-Head:** 0092
> Diese Datei ist die einzige verbindliche Quelle für den Reparatur- und Abschlussplan.
> Alle früheren Umbau- und Abschlussdokumente sind überholt.
---
## Verbindliche Regeln
1. Keine neue Zielarchitektur entwerfen.
2. Keine Microservices einführen.
3. Keine neuen generischen Security-, Entity-, Storage- oder Agentenplattformen bauen.
4. Bestehende Services nicht vollständig auf Commands umbauen.
5. Keine Beispiele als Produktanforderungen behandeln.
6. Keine Migration bis einschließlich `0092` erneut verändern.
7. Schemafehler ausschließlich über neue Forward-Migrationen korrigieren.
8. Keine produktiven Daten automatisch zusammenführen oder löschen.
9. Keine manuellen Änderungen in laufenden Coolify-Containern.
10. Jeder Arbeitsschritt benötigt: konkreten Fehler, begrenzte Codeänderung, reproduzierbaren Test, eigenen Git-Commit.
---
## Phasen-Status
| Phase | Status | Hinweis |
|-------|--------|---------|
| 0 — Stand sichern | ✅ Abgeschlossen | Tag + Branch + RECOVERY_SCOPE.md |
| 1 — Migrationen & Zielschema | ⏳ Nicht begonnen | Migrationsaudit + Forward-Migrationen ab 0093 |
| 2 — Security & Permissions | 🔶 Teilweise erledigt | Workspace-Permissions registriert, Frontend-Fallback entfernt. RLS-Tests offen. |
| 3 — Doppelte Command-Struktur | ⏳ Nicht begonnen | app/core/commands.py + app/commands/create_contact.py entfernen |
| 4 — Workspaces | 🔶 Teilweise erledigt | Widget-Fixes, Permissions, Context is_visible, AVAILABLE_MODULES entfernt. Kalenderauswahl, Modulunterpunkte offen. |
| 5 — AI & MCP | ⏳ Nicht begonnen | Delegationstoken, Bearer-Auth, Pfadbegrenzung |
| 6 — DMS & Attachments | ⏳ Nicht begonnen | Streaming, Deduplikation, Alt-Migration |
| 7 — Plugins, Worker, Outbox | ⏳ Nicht begonnen | Plugin-Gate, Event-Envelope, Handler-Tracking |
| 8 — CI, Restore, Coolify | ⏳ Nicht begonnen | Merge-CI, Migrations-Gate, Restore-Test |
| 9 — Abschluss | ⏳ Nicht begonnen | RECOVERY_ACCEPTANCE_REPORT.md |
---
## Produktionsstand (Phase 0.1)
- **Git-Commit:** 3cbf921 (main)
- **Alembic-Version:** 0092
- **Produktions-URL:** https://crm.media-on.de — healthy
- **API:** healthy, Worker: healthy
- **RLS-Tabellen:** 109
- **Attachments (alt):** 0
- **Entity-Attachments:** 2
- **DMS-Dateien:** 17
- **Workspaces:** 2
---
## Überholte Dokumente
Folgende Dokumente sind nicht mehr als Umsetzungsanweisung zu verwenden:
- `docs/ABSCHLUSSBERICHT_PHASE0_PHASE1.md` — ÜBERHOLT
- `SANIERUNGS_FORTSCHRITT.md` — ÜBERHOLT
- `docs/phase0_phase1_acceptance_report.md` — ÜBERHOLT
+1
View File
@@ -1,3 +1,4 @@
ÜBERHOLT NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
# Phase 0 + Phase 1 — Abschluss-Abnahmeprotokoll
**Stand:** 2026-07-31 12:06 CEST