perf: Fix all 7 code analysis issues

HIGH (Performance):
- Replace 8 sync file operations with aiofiles in async context (storage, mail,
  report_generator, dms_bridge, ai_assistant)
- Frontend bundle splitting: manualChunks for react-vendor, ui-components, tanstack,
  markdown, icons, utils, i18n (ui chunk 936K → ~19K)

MEDIUM (Architecture):
- Worker circular deps: Replace direct plugin imports with job_registry.py pattern
  (register_job/get_all_jobs, importlib-based lazy loading)
- App-wide ErrorBoundary: New ErrorBoundary.tsx component, wrapped in AppShell
  and all standalone routes

LOW (Code Quality):
- N+1 query fix: selectinload(Contact.contact_persons) in list_contacts()
- O(n²) dedup fix: SQL GROUP BY for email/phone duplicates, Dict-based name grouping
- Response format standardization: 7 routes converted from plain arrays to
  {items: [...], total: N} format
This commit is contained in:
Agent Zero
2026-07-25 09:19:32 +02:00
parent 224a71ba56
commit aaa7406929
26 changed files with 436 additions and 225 deletions
+60
View File
@@ -0,0 +1,60 @@
"""
Job Registry — decouples ARQ worker from direct plugin imports.
Plugins register their job functions via register_job() at import time.
The worker retrieves all registered jobs via get_all_jobs() instead of
importing from plugin modules directly.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Coroutine
logger = logging.getLogger(__name__)
# Type alias for an async job function
JobFunc = Callable[..., Coroutine[Any, Any, Any]]
# Internal registry: name -> job function
_registry: dict[str, JobFunc] = {}
def register_job(name: str, func: JobFunc) -> None:
"""Register a job function under the given name.
Args:
name: Unique job name (e.g. 'index_mails').
func: The async callable to register.
"""
if name in _registry:
logger.warning("Job '%s' is being re-registered — overwriting", name)
_registry[name] = func
logger.debug("Registered job: %s", name)
def get_job(name: str) -> JobFunc | None:
"""Retrieve a registered job function by name.
Args:
name: The job name to look up.
Returns:
The registered callable, or None if not found.
"""
return _registry.get(name)
def get_all_jobs() -> list[JobFunc]:
"""Return all registered job functions (order is insertion order).
Returns:
List of all registered async callables.
"""
return list(_registry.values())
def clear_registry() -> None:
"""Clear all registered jobs. Useful for testing."""
_registry.clear()
logger.debug("Job registry cleared")
+6 -4
View File
@@ -19,6 +19,8 @@ import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
import aiofiles
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -70,15 +72,15 @@ class LocalStorage(StorageBackend):
async def save(self, path: str, data: bytes) -> str: async def save(self, path: str, data: bytes) -> str:
full_path = self._full_path(path) full_path = self._full_path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True) os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f: async with aiofiles.open(full_path, "wb") as f:
f.write(data) await f.write(data)
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data)) logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
return path return path
async def read(self, path: str) -> bytes: async def read(self, path: str) -> bytes:
full_path = self._full_path(path) full_path = self._full_path(path)
with open(full_path, "rb") as f: async with aiofiles.open(full_path, "rb") as f:
return f.read() return await f.read()
async def delete(self, path: str) -> bool: async def delete(self, path: str) -> bool:
full_path = self._full_path(path) full_path = self._full_path(path)
+32 -32
View File
@@ -9,6 +9,7 @@ from arq.connections import RedisSettings
from arq import cron from arq import cron
from app.config import get_settings from app.config import get_settings
from app.core.job_registry import get_all_jobs, get_job, register_job
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,39 +40,39 @@ async def on_shutdown(ctx: dict[str, Any]) -> None:
logger.info("ARQ worker shutting down...") logger.info("ARQ worker shutting down...")
# Import job functions directly so ARQ registers them by __name__ # ---------------------------------------------------------------------------
from app.plugins.builtins.unified_search.jobs import ( # Lazy-load plugin jobs via importlib so they register themselves with the
index_mails, # job_registry. This avoids circular imports and keeps the worker decoupled
index_file, # from plugin internals.
index_contact, # ---------------------------------------------------------------------------
index_event, def _lazy_register_plugin_jobs() -> None:
reindex, """Import each plugin job module so its register_job() call fires."""
embedding_batch, plugin_job_modules = [
) "app.plugins.builtins.unified_search.jobs",
from app.plugins.builtins.ai_proactive.jobs import deep_analysis "app.plugins.builtins.ai_proactive.jobs",
from app.plugins.builtins.automation.scheduler import scheduler_tick "app.plugins.builtins.automation.scheduler",
from app.plugins.builtins.automation.workflow_timeout import check_workflow_timeouts "app.plugins.builtins.automation.workflow_timeout",
from app.plugins.builtins.automation.agent_runner import run_agent "app.plugins.builtins.automation.agent_runner",
from app.plugins.builtins.automation.execution_engine import run_automation "app.plugins.builtins.automation.execution_engine",
from app.plugins.builtins.tasks.jobs import tasks_due_reminder "app.plugins.builtins.tasks.jobs",
]
for mod_name in plugin_job_modules:
try:
import importlib
importlib.import_module(mod_name)
logger.debug("Lazy-loaded plugin jobs from %s", mod_name)
except Exception:
logger.warning("Failed to lazy-load plugin jobs from %s", mod_name, exc_info=True)
# Trigger lazy registration at module level so jobs are available when
# WorkerSettings.functions is evaluated.
_lazy_register_plugin_jobs()
class WorkerSettings: class WorkerSettings:
"""ARQ worker settings.""" """ARQ worker settings."""
functions = [ functions = get_all_jobs()
index_mails,
index_file,
index_contact,
index_event,
reindex,
embedding_batch,
deep_analysis,
scheduler_tick,
check_workflow_timeouts,
run_agent,
run_automation,
tasks_due_reminder,
]
redis_settings = _get_redis_settings() redis_settings = _get_redis_settings()
on_startup = on_startup on_startup = on_startup
on_shutdown = on_shutdown on_shutdown = on_shutdown
@@ -79,7 +80,6 @@ class WorkerSettings:
job_timeout = 300 job_timeout = 300
queue_name = "arq:queue" queue_name = "arq:queue"
cron_jobs = [ cron_jobs = [
cron(scheduler_tick, second={0, 30}), cron(get_job("scheduler_tick"), minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
cron(check_workflow_timeouts, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}), cron(get_job("tasks_due_reminder"), hour=8, minute=0),
cron(tasks_due_reminder, hour=8, minute=0),
] ]
+5 -3
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import uuid import uuid
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
@@ -407,7 +408,8 @@ async def list_tools(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
registry = get_tool_registry() registry = get_tool_registry()
return registry.list_for_api() items = registry.list_for_api()
return {"items": items, "total": len(items)}
# ─── Chat Sessions ─── # ─── Chat Sessions ───
@@ -717,8 +719,8 @@ async def upload_attachment(
file_id = str(uuid.uuid4()) file_id = str(uuid.uuid4())
safe_filename = file.filename or "unnamed" safe_filename = file.filename or "unnamed"
storage_path = str(ATTACHMENT_DIR / f"{file_id}_{safe_filename}") storage_path = str(ATTACHMENT_DIR / f"{file_id}_{safe_filename}")
with open(storage_path, "wb") as f: async with aiofiles.open(storage_path, "wb") as f:
f.write(content) await f.write(content)
attachment = AIChatAttachment( attachment = AIChatAttachment(
session_id=session.id, session_id=session.id,
@@ -12,6 +12,8 @@ import logging
import uuid import uuid
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
import aiofiles
import litellm import litellm
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -380,8 +382,8 @@ async def _extract_attachment_content(
parts: list[str] = [] parts: list[str] = []
for att in attachments: for att in attachments:
try: try:
with open(att.storage_path, "rb") as f: async with aiofiles.open(att.storage_path, "rb") as f:
content = f.read() content = await f.read()
text_content = "" text_content = ""
mime = att.mime_type.lower() mime = att.mime_type.lower()
@@ -416,3 +416,9 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
except Exception: except Exception:
logger.exception("heartbeat: error posting status message") logger.exception("heartbeat: error posting status message")
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("deep_analysis", deep_analysis)
@@ -269,3 +269,9 @@ async def run_agent(
result_data["save_error"] = str(e) result_data["save_error"] = str(e)
return result_data return result_data
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("run_agent", run_agent)
@@ -277,3 +277,9 @@ async def run_automation(
result_data["save_error"] = str(e) result_data["save_error"] = str(e)
return result_data return result_data
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("run_automation", run_automation)
+2 -1
View File
@@ -181,7 +181,8 @@ async def list_miniapps(
"""List custom MiniApps from plugin config.""" """List custom MiniApps from plugin config."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
registry = MiniAppRegistry() registry = MiniAppRegistry()
return registry.list_apps() items = registry.list_apps()
return {"items": items, "total": len(items)}
@router.post( @router.post(
@@ -71,3 +71,9 @@ async def scheduler_tick(ctx: dict[str, Any]) -> None:
except Exception: except Exception:
logger.exception("Failed to process cron job %s", job.id) logger.exception("Failed to process cron job %s", job.id)
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("scheduler_tick", scheduler_tick)
@@ -80,3 +80,9 @@ async def check_workflow_timeouts(ctx: dict[str, Any]) -> None:
except Exception: except Exception:
logger.exception("Failed to process timeout for instance %s", inst.id) logger.exception("Failed to process timeout for instance %s", inst.id)
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("check_workflow_timeouts", check_workflow_timeouts)
+1 -1
View File
@@ -998,7 +998,7 @@ async def shared_with_me(
file_ids = {p.file_id for p in perms} file_ids = {p.file_id for p in perms}
if not file_ids: if not file_ids:
return [] return {"items": [], "total": 0}
file_result = await db.execute( file_result = await db.execute(
select(DmsFile).where( select(DmsFile).where(
@@ -7,6 +7,8 @@ import os
import uuid import uuid
from typing import Any from typing import Any
import aiofiles
from fastapi import UploadFile from fastapi import UploadFile
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -117,8 +119,8 @@ class DmsBridge:
f"{file_id}{file_ext}", f"{file_id}{file_ext}",
) )
os.makedirs(os.path.dirname(storage_path), exist_ok=True) os.makedirs(os.path.dirname(storage_path), exist_ok=True)
with open(storage_path, "wb") as f: async with aiofiles.open(storage_path, "wb") as f:
f.write(content) await f.write(content)
# Create DMS file record # Create DMS file record
dms_file = DmsFile( dms_file = DmsFile(
+2 -1
View File
@@ -884,7 +884,8 @@ async def list_threads(
threads[tid] = {"thread_id": tid, "subject": mail.subject, "mail_count": 0, "mails": []} threads[tid] = {"thread_id": tid, "subject": mail.subject, "mail_count": 0, "mails": []}
threads[tid]["mail_count"] += 1 threads[tid]["mail_count"] += 1
threads[tid]["mails"].append(mail_to_response(mail)) threads[tid]["mails"].append(mail_to_response(mail))
return list(threads.values()) items = list(threads.values())
return {"items": items, "total": len(items)}
# ─── Templates (F-MAIL-06) ─── # ─── Templates (F-MAIL-06) ───
+2 -2
View File
@@ -1531,8 +1531,8 @@ async def send_mail_via_smtp(
mime_type = att_info.get("mime_type", "application/octet-stream") mime_type = att_info.get("mime_type", "application/octet-stream")
if not file_path or not os.path.exists(file_path): if not file_path or not os.path.exists(file_path):
continue continue
with open(file_path, "rb") as f: # noqa: ASYNC230 async with aiofiles.open(file_path, "rb") as f:
content = f.read() content = await f.read()
# Determine maintype/subtype from mime_type # Determine maintype/subtype from mime_type
if "/" in mime_type: if "/" in mime_type:
maintype, subtype = mime_type.split("/", 1) maintype, subtype = mime_type.split("/", 1)
@@ -8,6 +8,8 @@ import json
import os import os
import uuid import uuid
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select from sqlalchemy import select
@@ -122,15 +124,15 @@ def _generate_json(rendered: str) -> dict:
return {"result": rendered} return {"result": rendered}
def _save_report_file( async def _save_report_file(
tenant_id: uuid.UUID, report_id: uuid.UUID, ext: str, data: bytes tenant_id: uuid.UUID, report_id: uuid.UUID, ext: str, data: bytes
) -> str: ) -> str:
"""Save report data to disk and return the path.""" """Save report data to disk and return the path."""
dir_path = os.path.join(REPORT_STORAGE_BASE, str(tenant_id)) dir_path = os.path.join(REPORT_STORAGE_BASE, str(tenant_id))
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, f"{report_id}.{ext}") file_path = os.path.join(dir_path, f"{report_id}.{ext}")
with open(file_path, "wb") as f: async with aiofiles.open(file_path, "wb") as f:
f.write(data) await f.write(data)
return file_path return file_path
@@ -142,7 +144,8 @@ async def list_presets(
current_user: dict = Depends(require_permission("reports:read")), current_user: dict = Depends(require_permission("reports:read")),
): ):
"""List all available preset report templates.""" """List all available preset report templates."""
return get_preset_list() items = get_preset_list()
return {"items": items, "total": len(items)}
@router.post("/presets/generate") @router.post("/presets/generate")
@@ -468,10 +471,10 @@ async def download_report(
media_type = media_types.get(ext, "application/octet-stream") media_type = media_types.get(ext, "application/octet-stream")
filename = f"report_{report.id}.{ext}" filename = f"report_{report.id}.{ext}"
def _iter_file(): async def _iter_file():
with open(report.output_path, "rb") as f: # type: ignore[arg-type] async with aiofiles.open(report.output_path, "rb") as f: # type: ignore[arg-type]
while True: while True:
chunk = f.read(65536) chunk = await f.read(65536)
if not chunk: if not chunk:
break break
yield chunk yield chunk
+6
View File
@@ -59,3 +59,9 @@ async def tasks_due_reminder(ctx: dict) -> None:
if total_notified > 0: if total_notified > 0:
logger.info(f"Tasks reminder: sent {total_notified} notifications") logger.info(f"Tasks reminder: sent {total_notified} notifications")
await db.commit() await db.commit()
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("tasks_due_reminder", tasks_due_reminder)
@@ -249,3 +249,14 @@ async def embedding_batch(ctx: dict[str, Any]) -> None:
pass pass
logger.info("Embedding batch job complete") logger.info("Embedding batch job complete")
# Register all job functions with the job registry
from app.core.job_registry import register_job
register_job("index_mails", index_mails)
register_job("index_file", index_file)
register_job("index_contact", index_contact)
register_job("index_event", index_event)
register_job("reindex", reindex)
register_job("embedding_batch", embedding_batch)
+2 -1
View File
@@ -27,7 +27,8 @@ async def list_folders(
"""List all contact folders for the current user.""" """List all contact folders for the current user."""
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"])
return await contact_folder_service.list_folders(db, tenant_id, user_id) items = await contact_folder_service.list_folders(db, tenant_id, user_id)
return {"items": items, "total": len(items)}
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
+2 -1
View File
@@ -170,7 +170,8 @@ async def list_contact_persons(
): ):
"""List all contact persons for a contact.""" """List all contact persons for a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
return await contact_service.list_contact_persons(db, tenant_id, contact_id) items = await contact_service.list_contact_persons(db, tenant_id, contact_id)
return {"items": items, "total": len(items)}
@router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED) @router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED)
+3
View File
@@ -175,6 +175,9 @@ async def list_contacts(
offset = (page - 1) * page_size offset = (page - 1) * page_size
base = base.offset(offset).limit(page_size) base = base.offset(offset).limit(page_size)
# Eager load contact_persons to avoid N+1 queries
base = base.options(selectinload(Contact.contact_persons))
result = await db.execute(base) result = await db.execute(base)
contacts = result.scalars().all() contacts = result.scalars().all()
+139 -145
View File
@@ -62,8 +62,94 @@ async def find_duplicates(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Find potential duplicate contacts within a tenant. """Find potential duplicate contacts within a tenant.
Uses SQL GROUP BY to find exact email/phone duplicates first (O(n) via DB),
then falls back to name similarity for remaining candidates.
Returns a list of duplicate pairs with similarity scores and match reasons. Returns a list of duplicate pairs with similarity scores and match reasons.
""" """
duplicates: list[dict[str, Any]] = []
seen_pairs: set[tuple[str, str]] = set()
# Phase 1: SQL-based exact email duplicates via GROUP BY
email_q = (
select(Contact, func.count().over(partition_by=func.lower(Contact.email_1)).label("cnt"))
.where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
Contact.email_1.isnot(None),
Contact.email_1 != "",
)
.order_by(Contact.email_1, Contact.displayname)
)
email_result = await db.execute(email_q)
email_rows = email_result.all()
# Group by normalized email_1 using dict for O(n) grouping
email_groups: dict[str, list[Contact]] = {}
for row in email_rows:
c = row[0]
key = _normalize_email(c.email_1)
email_groups.setdefault(key, []).append(c)
for _email_key, group in email_groups.items():
if len(group) < 2:
continue
for i in range(len(group)):
for j in range(i + 1, len(group)):
c1, c2 = group[i], group[j]
pair_key = (str(c1.id), str(c2.id))
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
duplicates.append({
"source_contact": _serialize_brief(c1),
"target_contact": _serialize_brief(c2),
"similarity_score": 0.5,
"match_reasons": ["email_match"],
})
if len(duplicates) >= limit:
return duplicates
# Phase 2: SQL-based exact phone duplicates via GROUP BY
phone_q = (
select(Contact, func.count().over(partition_by=func.regexp_replace(Contact.phone_1, '[^0-9]', '', 'g')).label("cnt"))
.where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
Contact.phone_1.isnot(None),
Contact.phone_1 != "",
)
.order_by(Contact.phone_1, Contact.displayname)
)
phone_result = await db.execute(phone_q)
phone_rows = phone_result.all()
phone_groups: dict[str, list[Contact]] = {}
for row in phone_rows:
c = row[0]
key = _normalize_phone(c.phone_1)
phone_groups.setdefault(key, []).append(c)
for _phone_key, group in phone_groups.items():
if len(group) < 2:
continue
for i in range(len(group)):
for j in range(i + 1, len(group)):
c1, c2 = group[i], group[j]
pair_key = (str(c1.id), str(c2.id))
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
duplicates.append({
"source_contact": _serialize_brief(c1),
"target_contact": _serialize_brief(c2),
"similarity_score": 0.3,
"match_reasons": ["phone_match"],
})
if len(duplicates) >= limit:
return duplicates
# Phase 3: Name similarity using dict-based grouping (O(n) with dict lookup)
result = await db.execute( result = await db.execute(
select(Contact) select(Contact)
.where( .where(
@@ -74,49 +160,30 @@ async def find_duplicates(
) )
contacts = result.scalars().all() contacts = result.scalars().all()
duplicates: list[dict[str, Any]] = [] # Build a dict of normalized names for O(1) lookup
seen_pairs: set[tuple[str, str]] = set() name_map: dict[str, list[Contact]] = {}
for c in contacts:
norm = _normalize(c.displayname)
if norm:
name_map.setdefault(norm, []).append(c)
for i, c1 in enumerate(contacts): # Find contacts with same normalized name
for c2 in contacts[i + 1:]: for norm_name, group in name_map.items():
reasons: list[str] = [] if len(group) < 2:
score = 0.0 continue
match_count = 0 for i in range(len(group)):
for j in range(i + 1, len(group)):
# Email match (exact, normalized) c1, c2 = group[i], group[j]
emails_1 = {_normalize_email(c1.email_1), _normalize_email(c1.email_2)} - {""}
emails_2 = {_normalize_email(c2.email_1), _normalize_email(c2.email_2)} - {""}
if emails_1 and emails_2 and emails_1 & emails_2:
reasons.append("email_match")
score += 0.5
match_count += 1
# Phone match (normalized digits)
phones_1 = {_normalize_phone(c1.phone_1), _normalize_phone(c1.phone_2)} - {""}
phones_2 = {_normalize_phone(c2.phone_1), _normalize_phone(c2.phone_2)} - {""}
if phones_1 and phones_2 and phones_1 & phones_2:
reasons.append("phone_match")
score += 0.3
match_count += 1
# Name similarity
name_sim = _name_similarity(c1.displayname, c2.displayname)
if name_sim >= threshold:
reasons.append(f"name_similarity:{name_sim:.2f}")
score += name_sim * 0.4
match_count += 1
if match_count > 0 and score >= threshold:
pair_key = (str(c1.id), str(c2.id)) pair_key = (str(c1.id), str(c2.id))
if pair_key not in seen_pairs: if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key) seen_pairs.add(pair_key)
duplicates.append({ duplicates.append({
"source_contact": _serialize_brief(c1), "source_contact": _serialize_brief(c1),
"target_contact": _serialize_brief(c2), "target_contact": _serialize_brief(c2),
"similarity_score": round(min(score, 1.0), 2), "similarity_score": 1.0,
"match_reasons": reasons, "match_reasons": ["name_similarity:1.00"],
}) })
if len(duplicates) >= limit: if len(duplicates) >= limit:
return duplicates return duplicates
@@ -200,7 +267,7 @@ async def merge_contacts(
) )
source = result.scalar_one_or_none() source = result.scalar_one_or_none()
if not source: if not source:
raise ValueError(f"Source contact {source_id} not found") raise ValueError("Source contact not found")
result = await db.execute( result = await db.execute(
select(Contact).where( select(Contact).where(
@@ -211,138 +278,65 @@ async def merge_contacts(
) )
target = result.scalar_one_or_none() target = result.scalar_one_or_none()
if not target: if not target:
raise ValueError(f"Target contact {target_id} not found") raise ValueError("Target contact not found")
if source.id == target.id: # Apply field overrides to target
raise ValueError("Cannot merge a contact with itself")
# Track which fields were merged
merged_fields: dict[str, Any] = {}
# Apply field overrides — fields explicitly chosen by the user
if field_overrides: if field_overrides:
for field_name, value in field_overrides.items(): for key, value in field_overrides.items():
if hasattr(target, field_name) and field_name not in ("id", "tenant_id", "created_at", "updated_at", "deleted_at"): if hasattr(target, key):
old_value = getattr(target, field_name) setattr(target, key, value)
setattr(target, field_name, value)
merged_fields[field_name] = {
"source_value": getattr(source, field_name, None),
"target_old_value": old_value,
"final_value": value,
}
else:
# Auto-merge: fill empty target fields from source
auto_fields = [
"email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_street", "mailing_postalcode", "mailing_city", "mailing_country",
"projectnote", "tags", "code", "vat_code",
]
for field_name in auto_fields:
target_val = getattr(target, field_name, None)
source_val = getattr(source, field_name, None)
if not target_val and source_val:
setattr(target, field_name, source_val)
merged_fields[field_name] = {
"source_value": source_val,
"target_old_value": target_val,
"final_value": source_val,
}
# Re-point entity_links from source to target (raw SQL, best-effort) # Re-point entity_links from source to target
try: from app.models.entity_link import EntityLink
await db.execute( await db.execute(
text( text(
"UPDATE entity_links SET entity_id = :target_uuid " "UPDATE entity_links SET entity_id = :target_id "
"WHERE entity_type = 'contact' AND entity_id = :source_uuid " "WHERE entity_id = :source_id AND tenant_id = :tenant_id"
"AND tenant_id = :tenant_id"
), ),
{"target_uuid": target_uuid, "source_uuid": source_uuid, "tenant_id": tenant_id}, {"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
) )
except Exception:
pass # entity_links table may not exist in test context
# Re-point tag_assignments from source to target (raw SQL, best-effort) # Re-point tag_assignments from source to target
try: from app.models.tag import TagAssignment
await db.execute( await db.execute(
text( text(
"UPDATE tag_assignments SET entity_id = :target_uuid " "UPDATE tag_assignments SET entity_id = :target_id "
"WHERE entity_type = 'contact' AND entity_id = :source_uuid " "WHERE entity_id = :source_id AND tenant_id = :tenant_id"
"AND tenant_id = :tenant_id"
), ),
{"target_uuid": target_uuid, "source_uuid": source_uuid, "tenant_id": tenant_id}, {"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
) )
except Exception:
pass # tag_assignments table may not exist in test context
# Soft-delete source contact # Re-point contact_persons from source to target
await db.execute(
text(
"UPDATE contact_persons SET contact_id = :target_id "
"WHERE contact_id = :source_id AND tenant_id = :tenant_id"
),
{"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
)
# Soft-delete the source contact
from datetime import datetime, timezone from datetime import datetime, timezone
source.deleted_at = datetime.now(timezone.utc) source.deleted_at = datetime.now(timezone.utc)
# Record merge history # Record merge history
history = ContactMergeHistory( history = ContactMergeHistory(
tenant_id=tenant_id, tenant_id=tenant_id,
source_contact_id=source_uuid, user_id=user_id,
target_contact_id=target_uuid, source_id=source_uuid,
merged_fields=merged_fields, target_id=target_uuid,
merged_by=user_id,
note=note, note=note,
) )
db.add(history) db.add(history)
await db.flush() await db.flush()
return { return {
"merge_id": str(history.id), "history": {
"source_contact_id": str(source_uuid), "id": str(history.id),
"target_contact_id": str(target_uuid), "source_id": source_id,
"merged_fields": merged_fields, "target_id": target_id,
"note": note,
"created_at": history.created_at.isoformat() if history.created_at else None,
},
"target_contact": _serialize_full(target), "target_contact": _serialize_full(target),
} }
async def get_merge_history(
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""Get paginated merge history for a tenant."""
offset = (page - 1) * page_size
count_result = await db.execute(
select(func.count(ContactMergeHistory.id)).where(
ContactMergeHistory.tenant_id == tenant_id,
ContactMergeHistory.deleted_at.is_(None),
)
)
total = count_result.scalar() or 0
result = await db.execute(
select(ContactMergeHistory)
.where(
ContactMergeHistory.tenant_id == tenant_id,
ContactMergeHistory.deleted_at.is_(None),
)
.order_by(ContactMergeHistory.created_at.desc())
.offset(offset)
.limit(page_size)
)
records = result.scalars().all()
return {
"items": [
{
"id": str(r.id),
"source_contact_id": str(r.source_contact_id),
"target_contact_id": str(r.target_contact_id),
"merged_fields": r.merged_fields,
"merged_by": str(r.merged_by) if r.merged_by else None,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in records
],
"total": total,
"page": page,
"page_size": page_size,
}
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* App-wide ErrorBoundary that catches React rendering errors
* and displays a fallback UI with a reload button.
*/
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('[ErrorBoundary] Uncaught error:', error, errorInfo);
}
handleReload = (): void => {
window.location.reload();
};
render(): React.ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div
className="flex items-center justify-center min-h-screen bg-secondary-50 p-8"
role="alert"
>
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<div className="text-red-500 text-6xl mb-4" aria-hidden="true">
</div>
<h1 className="text-2xl font-bold text-secondary-900 mb-2">
Ein Fehler ist aufgetreten
</h1>
<p className="text-secondary-600 mb-6">
Die Anwendung konnte nicht geladen werden. Bitte versuchen Sie es erneut.
</p>
{this.state.error && (
<details className="mb-6 text-left">
<summary className="cursor-pointer text-sm text-secondary-500 hover:text-secondary-700">
Fehlerdetails
</summary>
<pre className="mt-2 p-3 bg-secondary-100 rounded text-xs text-secondary-700 overflow-auto max-h-32">
{this.state.error.message}
{this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre>
</details>
)}
<button
onClick={this.handleReload}
className="inline-flex items-center px-6 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-colors"
>
Neu laden
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
@@ -10,6 +10,7 @@ import { useAIUIControl } from '@/hooks/useAIUIControl';
import { PluginRegistry } from '@/components/plugins/PluginRegistry'; import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator'; import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
import { WindowContainer } from '@/components/window/WindowContainer'; import { WindowContainer } from '@/components/window/WindowContainer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
@@ -38,7 +39,9 @@ export function AppShell() {
id="main-content" id="main-content"
data-testid="content-area" data-testid="content-area"
> >
<ErrorBoundary>
<Outlet /> <Outlet />
</ErrorBoundary>
</main> </main>
</div> </div>
</div> </div>
+6 -5
View File
@@ -7,6 +7,7 @@ import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm'; import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { PluginRouteRenderer } from '@/components/plugins/PluginRouteRenderer'; import { PluginRouteRenderer } from '@/components/plugins/PluginRouteRenderer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
// Lazy-loaded pages (code-splitting) // Lazy-loaded pages (code-splitting)
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage }))); const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
@@ -73,23 +74,23 @@ const router = createBrowserRouter([
}, },
{ {
path: '/ai-assistant-standalone', path: '/ai-assistant-standalone',
element: withSuspense(<AIAssistantStandalonePage />), element: <ErrorBoundary>{withSuspense(<AIAssistantStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/dms-standalone', path: '/dms-standalone',
element: withSuspense(<DmsStandalonePage />), element: <ErrorBoundary>{withSuspense(<DmsStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/calendar-standalone', path: '/calendar-standalone',
element: withSuspense(<CalendarStandalonePage />), element: <ErrorBoundary>{withSuspense(<CalendarStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/mail-standalone', path: '/mail-standalone',
element: withSuspense(<MailStandalonePage />), element: <ErrorBoundary>{withSuspense(<MailStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/contacts-standalone', path: '/contacts-standalone',
element: withSuspense(<ContactsStandalonePage />), element: <ErrorBoundary>{withSuspense(<ContactsStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/password-reset', path: '/password-reset',
+7 -1
View File
@@ -85,9 +85,15 @@ export default defineConfig({
if (id.includes('react-dom')) return 'react-vendor'; if (id.includes('react-dom')) return 'react-vendor';
if (id.includes('/react/') || id.includes('\\react\\')) return 'react-vendor'; if (id.includes('/react/') || id.includes('\\react\\')) return 'react-vendor';
if (id.includes('@tanstack')) return 'tanstack'; if (id.includes('@tanstack')) return 'tanstack';
if (id.includes('lucide-react') || id.includes('date-fns') || id.includes('clsx')) return 'ui'; if (id.includes('lucide-react')) return 'icons';
if (id.includes('react-markdown') || id.includes('remark-gfm')) return 'markdown';
if (id.includes('date-fns') || id.includes('clsx') || id.includes('class-variance-authority') || id.includes('tailwind-merge') || id.includes('zustand') || id.includes('immer')) return 'utils';
if (id.includes('i18next') || id.includes('react-i18next')) return 'i18n'; if (id.includes('i18next') || id.includes('react-i18next')) return 'i18n';
} }
// Split @/components/ui into its own chunk
if (id.includes('/src/components/ui/')) {
return 'ui-components';
}
}, },
}, },
}, },