Files
leocrm/app/commands/contact_commands.py
T

396 lines
13 KiB
Python

"""Contact commands — Create, Update, Delete (soft), Merge.
Each command:
- Checks permissions via ``require_permission`` semantics
- Delegates business logic to existing services
- Creates an AuditLog entry
- Enqueues outbox events
- Validates state transitions via the state machine
Commands do NOT commit — the transaction is managed by ``get_db``.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
from app.core.state_machine import contact_state_machine, StateMachineError
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.services import contact_service, dedup_service
from app.services.entity_history_service import record_history
logger = logging.getLogger(__name__)
class CreateContactCommand(BaseCommand):
"""Create a new contact (company or person).
Args:
data: Contact fields dict (from ContactCreate schema).
"""
permission = "contacts:write"
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
self._created_contact_id: uuid.UUID | None = None
self._serialized: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Set default status for new contacts
if "status" not in self.data:
self.data["status"] = "lead"
# Validate status if provided
status = self.data.get("status", "lead")
if status not in contact_state_machine.transitions:
return CommandResult.fail(f"Invalid contact status: '{status}'")
# Delegate to existing service
try:
serialized = await contact_service.create_contact(
db, tenant_id, user_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._created_contact_id = uuid.UUID(serialized["id"])
self._serialized = serialized
# Handle company_ids — create ContactPerson links
company_ids = self.data.get("company_ids")
if company_ids:
from app.models.contact import ContactPerson
for cid in company_ids:
cp = ContactPerson(
tenant_id=tenant_id,
contact_id=uuid.UUID(cid),
displayname=serialized.get("displayname", ""),
firstname=self.data.get("firstname"),
lastname=self.data.get("surname"),
email=self.data.get("email_1"),
phone=self.data.get("phone_1"),
created_by=user_id,
updated_by=user_id,
)
db.add(cp)
await db.flush()
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": self.data.get("type", "company"),
})
events.append({"event": "contact.created", "contact_id": serialized["id"]})
if self.data.get("type") == "company":
await enqueue_outbox_event(db, tenant_id, "lead.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": "lead.created", "contact_id": serialized["id"]})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._created_contact_id is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="create",
entity_type="contact",
entity_id=self._created_contact_id,
changes=self._serialized,
)
db.add(entry)
await db.flush()
class UpdateContactCommand(BaseCommand):
"""Update an existing contact.
Args:
contact_id: UUID string of the contact to update.
data: Contact fields to update (from ContactUpdate schema).
"""
permission = "contacts:write"
def __init__(self, contact_id: str, data: dict[str, Any]) -> None:
self.contact_id = contact_id
self.data = data
self._contact_uuid: uuid.UUID | None = None
self._serialized: dict | None = None
self._changes: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Validate status transition if status is being updated
if "status" in self.data:
new_status = self.data["status"]
# Query only the status column to avoid lazy-loading issues
from sqlalchemy import select
status_q = select(Contact.status).where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
status_result = await db.execute(status_q)
current_status = status_result.scalar_one_or_none()
if current_status is None:
return CommandResult.fail("Contact not found")
try:
contact_state_machine.transition(current_status, new_status)
except StateMachineError as exc:
return CommandResult.fail(str(exc))
# Delegate to existing service
try:
serialized = await contact_service.update_contact(
db, tenant_id, user_id, self.contact_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._contact_uuid = uuid.UUID(self.contact_id)
self._serialized = serialized
# Compute changes for audit
self._changes = {k: {"new": v} for k, v in self.data.items()}
# Enqueue outbox event
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.updated", {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": serialized.get("type"),
})
events.append({"event": "contact.updated", "contact_id": self.contact_id})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="update",
entity_type="contact",
entity_id=self._contact_uuid,
changes=self._changes,
)
db.add(entry)
await db.flush()
class DeleteContactCommand(BaseCommand):
"""Soft-delete a contact.
Args:
contact_id: UUID string of the contact to delete.
hard: If True, perform GDPR hard-delete instead of soft-delete.
"""
permission = "contacts:delete"
def __init__(self, contact_id: str, hard: bool = False) -> None:
self.contact_id = contact_id
self.hard = hard
self._contact_uuid: uuid.UUID | None = None
self._snapshot: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Fetch contact for snapshot before deletion
from sqlalchemy import select
from sqlalchemy.orm import selectinload
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
)
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
return CommandResult.fail("Contact not found")
self._contact_uuid = contact.id
self._snapshot = contact_service._serialize_contact_detail(contact)
if self.hard:
await contact_service.hard_delete_contact(
db, tenant_id, self.contact_id
)
else:
await contact_service.delete_contact(
db, tenant_id, self.contact_id, user_id,
is_system_admin=current_user.get("is_system_admin", False),
)
# Enqueue outbox event
events: list[dict] = []
event_name = "contact.hard_deleted" if self.hard else "contact.deleted"
await enqueue_outbox_event(db, tenant_id, event_name, {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": event_name, "contact_id": self.contact_id})
return CommandResult.ok(data=None, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
action = "hard_delete" if self.hard else "delete"
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
entity_type="contact",
entity_id=self._contact_uuid,
changes={"snapshot": self._snapshot},
)
db.add(entry)
await db.flush()
class MergeContactsCommand(BaseCommand):
"""Merge two contacts (source → target).
Args:
source_contact_id: UUID string of the source contact (will be soft-deleted).
target_contact_id: UUID string of the target contact (will survive).
field_overrides: Optional field overrides to apply to the target.
note: Optional note for the merge history.
"""
permission = "contacts:write"
def __init__(
self,
source_contact_id: str,
target_contact_id: str,
field_overrides: dict[str, Any] | None = None,
note: str | None = None,
) -> None:
self.source_contact_id = source_contact_id
self.target_contact_id = target_contact_id
self.field_overrides = field_overrides
self.note = note
self._result_data: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self.source_contact_id == self.target_contact_id:
return CommandResult.fail("Source and target contacts must be different")
try:
result = await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=self.source_contact_id,
target_id=self.target_contact_id,
field_overrides=self.field_overrides,
note=self.note,
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._result_data = result
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.merged", {
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({
"event": "contact.merged",
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
})
return CommandResult.ok(data=result, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._result_data is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="merge",
entity_type="contact",
entity_id=uuid.UUID(self.target_contact_id),
changes={
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"note": self.note,
},
)
db.add(entry)
await db.flush()