Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""Command pattern package for LeoCRM.
|
||||
|
||||
Commands encapsulate business operations with:
|
||||
- Authorization (permission check)
|
||||
- Execution (delegates to services)
|
||||
- Audit logging
|
||||
- Outbox event enqueuing
|
||||
- State machine validation
|
||||
|
||||
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
|
||||
``get_db``) manages the transaction boundary.
|
||||
"""
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.commands.contact_commands import (
|
||||
CreateContactCommand,
|
||||
UpdateContactCommand,
|
||||
DeleteContactCommand,
|
||||
MergeContactsCommand,
|
||||
)
|
||||
from app.commands.dms_commands import (
|
||||
UploadFileCommand,
|
||||
DeleteFileCommand,
|
||||
)
|
||||
from app.commands.mail_commands import (
|
||||
SendMailCommand,
|
||||
MarkMailReadCommand,
|
||||
DeleteMailCommand,
|
||||
)
|
||||
from app.commands.calendar_commands import (
|
||||
CreateCalendarEntryCommand,
|
||||
UpdateCalendarEntryCommand,
|
||||
DeleteCalendarEntryCommand,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseCommand",
|
||||
"CommandResult",
|
||||
"CreateContactCommand",
|
||||
"UpdateContactCommand",
|
||||
"DeleteContactCommand",
|
||||
"MergeContactsCommand",
|
||||
"UploadFileCommand",
|
||||
"DeleteFileCommand",
|
||||
"SendMailCommand",
|
||||
"MarkMailReadCommand",
|
||||
"DeleteMailCommand",
|
||||
"CreateCalendarEntryCommand",
|
||||
"UpdateCalendarEntryCommand",
|
||||
"DeleteCalendarEntryCommand",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Base command infrastructure — CommandResult and BaseCommand.
|
||||
|
||||
Template method pattern:
|
||||
execute() → authorize() → run() → audit()
|
||||
|
||||
Commands must NOT call db.commit() or db.rollback().
|
||||
The transaction boundary is owned by the calling layer (FastAPI ``get_db``).
|
||||
Commands MAY call db.flush() to send SQL within the transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
success: Whether the command succeeded.
|
||||
data: Response data on success (dict or None).
|
||||
error: Error message on failure (str or None).
|
||||
events: List of outbox event dicts that were enqueued.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
data: dict | None = None
|
||||
error: str | None = None
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> "CommandResult":
|
||||
"""Create a successful result."""
|
||||
return cls(success=True, data=data, events=events or [])
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error: str) -> "CommandResult":
|
||||
"""Create a failed result."""
|
||||
return cls(success=False, error=error)
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
"""Base class for all commands using the template method pattern.
|
||||
|
||||
Subclasses must implement:
|
||||
- authorize(current_user) → check permissions
|
||||
- run(db, redis, current_user) → execute business logic, return CommandResult
|
||||
- audit(db, current_user) → create audit log entry
|
||||
|
||||
The ``permission`` attribute is checked by the default ``authorize``
|
||||
implementation. Set it to the required permission string (e.g. "contacts:write").
|
||||
"""
|
||||
|
||||
permission: str | None = None
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
"""Execute the command: authorize → run → audit.
|
||||
|
||||
Does NOT commit — the caller manages the transaction.
|
||||
"""
|
||||
# Authorization
|
||||
auth_result = await self.authorize(current_user)
|
||||
if not auth_result:
|
||||
return CommandResult.fail(
|
||||
f"Permission denied: '{self.permission}' required"
|
||||
)
|
||||
|
||||
# Execute business logic
|
||||
result = await self.run(db, redis, current_user)
|
||||
|
||||
# Audit (only if the command succeeded)
|
||||
if result.success:
|
||||
try:
|
||||
await self.audit(db, current_user)
|
||||
except Exception:
|
||||
logger.exception("Audit logging failed for %s", self.__class__.__name__)
|
||||
# Audit failure should not roll back the business operation
|
||||
|
||||
return result
|
||||
|
||||
async def authorize(self, current_user: dict[str, Any]) -> bool:
|
||||
"""Check if the current user has the required permission.
|
||||
|
||||
Override in subclass for custom authorization logic.
|
||||
"""
|
||||
if self.permission is None:
|
||||
return True
|
||||
return check_permission(current_user, self.permission)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
current_user: dict[str, Any],
|
||||
) -> CommandResult:
|
||||
"""Execute the business logic. Must be overridden by subclasses."""
|
||||
raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented")
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
"""Create an audit log entry. Override in subclass."""
|
||||
pass
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
@staticmethod
|
||||
def _tenant_id(current_user: dict[str, Any]) -> uuid.UUID:
|
||||
"""Extract tenant_id from current_user session."""
|
||||
return uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
@staticmethod
|
||||
def _user_id(current_user: dict[str, Any]) -> uuid.UUID:
|
||||
"""Extract user_id from current_user session."""
|
||||
return uuid.UUID(current_user["user_id"])
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Calendar commands — create, update, delete entries via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateCalendarEntryCommand(BaseCommand):
|
||||
"""Create a new calendar entry (appointment, task, reminder)."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, calendar_id: str, title: str, start_at: str, end_at: str | None = None,
|
||||
description: str | None = None, location: str | None = None,
|
||||
entry_type: str = "appointment", status: str = "open"):
|
||||
self.calendar_id = calendar_id
|
||||
self.title = title
|
||||
self.start_at = start_at
|
||||
self.end_at = end_at
|
||||
self.description = description
|
||||
self.location = location
|
||||
self.entry_type = entry_type
|
||||
self.status = status
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
cal_id = uuid.UUID(self.calendar_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid calendar_id")
|
||||
|
||||
# Verify calendar belongs to tenant
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)
|
||||
)
|
||||
if cal_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Calendar not found")
|
||||
|
||||
entry_id = uuid.uuid4()
|
||||
entry = CalendarEntry(
|
||||
id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=cal_id,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
location=self.location,
|
||||
start_at=datetime.fromisoformat(self.start_at),
|
||||
end_at=datetime.fromisoformat(self.end_at) if self.end_at else None,
|
||||
entry_type=self.entry_type,
|
||||
status=self.status,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.created", {
|
||||
"entry_id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(entry_id),
|
||||
"title": self.title,
|
||||
"start_at": self.start_at,
|
||||
"status": self.status,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.create", entity_type="calendar_entry",
|
||||
changes={"title": self.title, "start_at": self.start_at},
|
||||
)
|
||||
|
||||
|
||||
class UpdateCalendarEntryCommand(BaseCommand):
|
||||
"""Update an existing calendar entry."""
|
||||
|
||||
permission = "calendar:write"
|
||||
|
||||
def __init__(self, entry_id: str, data: dict[str, Any]):
|
||||
self.entry_id = entry_id
|
||||
self.data = data
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
# Apply updates
|
||||
for key, value in self.data.items():
|
||||
if hasattr(entry, key) and key not in ("id", "tenant_id", "created_at"):
|
||||
if key in ("start_at", "end_at") and isinstance(value, str):
|
||||
value = datetime.fromisoformat(value)
|
||||
setattr(entry, key, value)
|
||||
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.updated", {
|
||||
"entry_id": self.entry_id,
|
||||
"changes": self.data,
|
||||
})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "updated": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.update", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id, "fields": list(self.data.keys())},
|
||||
)
|
||||
|
||||
|
||||
class DeleteCalendarEntryCommand(BaseCommand):
|
||||
"""Delete a calendar entry."""
|
||||
|
||||
permission = "calendar:delete"
|
||||
|
||||
def __init__(self, entry_id: str):
|
||||
self.entry_id = entry_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
eid = uuid.UUID(self.entry_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid entry_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return CommandResult.fail("Calendar entry not found")
|
||||
|
||||
await db.delete(entry)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "calendar.entry.deleted", {"entry_id": self.entry_id})
|
||||
|
||||
return CommandResult.ok({"id": self.entry_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="calendar.entry.delete", entity_type="calendar_entry",
|
||||
changes={"entry_id": self.entry_id},
|
||||
)
|
||||
@@ -0,0 +1,375 @@
|
||||
"""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
|
||||
|
||||
# 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:write"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# 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()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""DMS commands — file upload, delete, restore via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
from app.core.storage import get_storage_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
safe = os.path.basename(filename.replace("\\", "/"))
|
||||
safe = re.sub(r"[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]", "_", safe)
|
||||
safe = re.sub(r"\.{2,}", "_", safe)
|
||||
safe = re.sub(r" {2,}", " ", safe)
|
||||
safe = safe.lstrip(".").strip()
|
||||
if len(safe) > 200:
|
||||
name, ext = safe.rsplit(".", 1) if "." in safe[:200] else (safe[:200], "")
|
||||
safe = name[:200] + ("." + ext if ext else "")
|
||||
return safe or "file"
|
||||
|
||||
|
||||
class UploadFileCommand(BaseCommand):
|
||||
"""Upload a file to DMS with chunked streaming and SHA-256 hashing."""
|
||||
|
||||
permission = "dms:write"
|
||||
|
||||
def __init__(self, file_content: bytes, filename: str, mime_type: str, folder_id: str | None = None):
|
||||
self.file_content = file_content
|
||||
self.filename = _sanitize_filename(filename)
|
||||
self.mime_type = mime_type
|
||||
self.folder_id = folder_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
# Validate folder if specified
|
||||
fid = None
|
||||
if self.folder_id:
|
||||
try:
|
||||
fid = uuid.UUID(self.folder_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid folder_id")
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None))
|
||||
)
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
return CommandResult.fail("Folder not found")
|
||||
|
||||
# Calculate SHA-256
|
||||
sha256 = hashlib.sha256()
|
||||
sha256.update(self.file_content)
|
||||
content_hash = sha256.hexdigest()
|
||||
file_size = len(self.file_content)
|
||||
|
||||
# Create file record
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = f"{tenant_id}/{file_id}"
|
||||
|
||||
# Save file
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, self.file_content)
|
||||
|
||||
dms_file = DmsFile(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
name=self.filename,
|
||||
folder_id=fid,
|
||||
uploaded_by=user_id,
|
||||
mime_type=self.mime_type,
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.uploaded", {
|
||||
"file_id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(file_id),
|
||||
"name": self.filename,
|
||||
"size_bytes": file_size,
|
||||
"content_hash": content_hash,
|
||||
"mime_type": self.mime_type,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.upload", entity_type="dms_file",
|
||||
changes={"name": self.filename, "size": len(self.file_content)},
|
||||
)
|
||||
|
||||
|
||||
class DeleteFileCommand(BaseCommand):
|
||||
"""Soft-delete a DMS file."""
|
||||
|
||||
permission = "dms:delete"
|
||||
|
||||
def __init__(self, file_id: str):
|
||||
self.file_id = file_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from datetime import UTC, datetime
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
fid = uuid.UUID(self.file_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid file_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None))
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
return CommandResult.fail("File not found")
|
||||
|
||||
dms_file.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "dms.file.deleted", {"file_id": self.file_id})
|
||||
|
||||
return CommandResult.ok({"id": self.file_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="dms.file.delete", entity_type="dms_file",
|
||||
changes={"file_id": self.file_id},
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Mail commands — send, mark read/unread, delete via Command pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.commands.base import BaseCommand, CommandResult
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SendMailCommand(BaseCommand):
|
||||
"""Send an email via a configured IMAP/SMTP account."""
|
||||
|
||||
permission = "mail:send"
|
||||
|
||||
def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None):
|
||||
self.account_id = account_id
|
||||
self.to = to
|
||||
self.subject = subject
|
||||
self.body_text = body_text
|
||||
self.body_html = body_html
|
||||
self.cc = cc or []
|
||||
self.in_reply_to = in_reply_to
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail, MailAccount
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
user_id = self._user_id(current_user)
|
||||
|
||||
try:
|
||||
account_uuid = uuid.UUID(self.account_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid account_id")
|
||||
|
||||
# Verify account belongs to tenant
|
||||
acct_result = await db.execute(
|
||||
select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id)
|
||||
)
|
||||
account = acct_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
return CommandResult.fail("Mail account not found")
|
||||
|
||||
# Create mail record
|
||||
mail_id = uuid.uuid4()
|
||||
mail = Mail(
|
||||
id=mail_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_uuid,
|
||||
message_id=f"<leocrm-{mail_id}@{account.email_address}>",
|
||||
from_addr=account.email_address,
|
||||
to_addr=",".join(self.to),
|
||||
cc_addr=",".join(self.cc) if self.cc else None,
|
||||
subject=self.subject,
|
||||
body_text=self.body_text,
|
||||
body_html_sanitized=self.body_html,
|
||||
direction="outgoing",
|
||||
received_at=datetime.now(UTC),
|
||||
is_read=True,
|
||||
folder="Sent",
|
||||
)
|
||||
db.add(mail)
|
||||
await db.flush()
|
||||
|
||||
# Enqueue outbox event for async SMTP send
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.send", {
|
||||
"mail_id": str(mail_id),
|
||||
"account_id": self.account_id,
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
return CommandResult.ok({
|
||||
"id": str(mail_id),
|
||||
"status": "queued",
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.send", entity_type="mail",
|
||||
changes={"to": self.to, "subject": self.subject},
|
||||
)
|
||||
|
||||
|
||||
class MarkMailReadCommand(BaseCommand):
|
||||
"""Mark a mail as read or unread."""
|
||||
|
||||
permission = "mail:write"
|
||||
|
||||
def __init__(self, mail_id: str, is_read: bool = True):
|
||||
self.mail_id = mail_id
|
||||
self.is_read = is_read
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.is_read = self.is_read
|
||||
await db.flush()
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.mark_read", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id, "is_read": self.is_read},
|
||||
)
|
||||
|
||||
|
||||
class DeleteMailCommand(BaseCommand):
|
||||
"""Soft-delete a mail."""
|
||||
|
||||
permission = "mail:delete"
|
||||
|
||||
def __init__(self, mail_id: str):
|
||||
self.mail_id = mail_id
|
||||
|
||||
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
|
||||
tenant_id = self._tenant_id(current_user)
|
||||
try:
|
||||
mid = uuid.UUID(self.mail_id)
|
||||
except ValueError:
|
||||
return CommandResult.fail("Invalid mail_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None))
|
||||
)
|
||||
mail = result.scalar_one_or_none()
|
||||
if mail is None:
|
||||
return CommandResult.fail("Mail not found")
|
||||
|
||||
mail.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id})
|
||||
|
||||
return CommandResult.ok({"id": self.mail_id, "deleted": True})
|
||||
|
||||
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
|
||||
from app.core.audit import log_audit
|
||||
await log_audit(
|
||||
db, self._tenant_id(current_user), self._user_id(current_user),
|
||||
action="mail.delete", entity_type="mail",
|
||||
changes={"mail_id": self.mail_id},
|
||||
)
|
||||
Reference in New Issue
Block a user