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:
@@ -1,3 +1,4 @@
|
|||||||
|
ÜBERHOLT – NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
|
||||||
# LeoCRM Sanierungsfortschritt
|
# LeoCRM Sanierungsfortschritt
|
||||||
|
|
||||||
**Letztes Update:** 2026-08-03
|
**Letztes Update:** 2026-08-03
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,3 +1,4 @@
|
|||||||
|
ÜBERHOLT – NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
|
||||||
# LeoCRM — Abschlussbericht Phase 0 + Phase 1 und vollständiger Sanierungsplan
|
# LeoCRM — Abschlussbericht Phase 0 + Phase 1 und vollständiger Sanierungsplan
|
||||||
|
|
||||||
**Datum:** 2026-08-01
|
**Datum:** 2026-08-01
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
ÜBERHOLT – NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
|
||||||
# Phase 0 + Phase 1 — Abschluss-Abnahmeprotokoll
|
# Phase 0 + Phase 1 — Abschluss-Abnahmeprotokoll
|
||||||
|
|
||||||
**Stand:** 2026-07-31 12:06 CEST
|
**Stand:** 2026-07-31 12:06 CEST
|
||||||
|
|||||||
Reference in New Issue
Block a user