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:
@@ -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
@@ -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
@@ -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),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) ───
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user