Phase 1C: Frontend unified contact UI
This commit is contained in:
+34
@@ -32,6 +32,40 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 1C: Frontend — Unified Contact UI
|
||||||
|
|
||||||
|
| Task | Status | Datum | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.18 | ✅ done | 2026-07-23 | Contact-Detail-Route /contacts/:id mit React.lazy |
|
||||||
|
| 1.19 | ✅ done | 2026-07-23 | ContactList Type-Filter Toggle (Alle/Firmen/Personen) |
|
||||||
|
| 1.20 | ✅ done | 2026-07-23 | ContactDetail ContactPerson-Verwaltung (bereits vorhanden) |
|
||||||
|
| 1.21 | ✅ done | 2026-07-23 | ContactEditModal für beide Types (bereits vorhanden) |
|
||||||
|
| 1.22 | ✅ done | 2026-07-23 | Company-Hooks aus hooks.ts entfernt |
|
||||||
|
| 1.23 | ✅ done | 2026-07-23 | Frontend Type-Definitions aktualisiert (calendar, tags, search, mail, types) |
|
||||||
|
| 1.24 | ✅ done | 2026-07-23 | Dashboard.tsx aktualisiert (keine Änderung nötig) |
|
||||||
|
| 1.25 | ✅ done | 2026-07-23 | GlobalSearchResults.tsx aktualisiert |
|
||||||
|
| 1.26 | ✅ done | 2026-07-23 | ContactFolderTree in ContactList integriert (bereits vorhanden) |
|
||||||
|
| 1.27 | ✅ done | 2026-07-23 | React Hook Form + Zod in ContactEditModal |
|
||||||
|
| 1.28 | ✅ done | 2026-07-23 | Frontend-Tests aktualisiert, tsc --noEmit OK (nur pre-existing Dms-Fehler) |
|
||||||
|
|
||||||
|
**Phase 1C Gesamt: ✅ Complete**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 1-7: Noch nicht gestartet
|
## Phase 1-7: Noch nicht gestartet
|
||||||
|
|
||||||
Siehe `MASTER-PLAN.md` für alle Tasks.
|
Siehe `MASTER-PLAN.md` für alle Tasks.
|
||||||
|
|
||||||
|
## Phase 2: Code-Splitting & Virtual Scrolling
|
||||||
|
|
||||||
|
| Task | Status | Datum | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | ✅ done | 2026-07-23 | React.lazy + Suspense für 25 Pages, PageLoader Komponente |
|
||||||
|
| 2.2 | ✅ done | 2026-07-23 | @tanstack/react-virtual installiert |
|
||||||
|
| 2.3 | ✅ done | 2026-07-23 | DataGrid Virtual Scrolling (useVirtualizer, 53px rows, 10 overscan, auto-skip <50) |
|
||||||
|
| 2.4 | ✅ done | 2026-07-23 | MailList Virtual Scrolling (80px rows, 8 overscan, auto-skip <50) |
|
||||||
|
| 2.5 | ✅ done | 2026-07-23 | ContactList Virtual Scrolling (list/table/cards, dynamic row estimate) |
|
||||||
|
| 2.6 | ✅ done | 2026-07-23 | FileExplorer + FileGrid virtualisiert, AuditLog via DataGrid |
|
||||||
|
| 2.7 | ✅ done | 2026-07-23 | Manual chunks: react-vendor, tanstack, ui, i18n. Vite build OK (3212 modules, ~9s) |
|
||||||
|
|
||||||
|
**Phase 2 Gesamt: ✅ Complete (Commit a8331fb)**
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unify entity_type 'company' to 'contact' across all plugins.
|
||||||
|
|
||||||
|
Revision ID: 0027
|
||||||
|
Revises: 0026_mail_salt_security
|
||||||
|
Create Date: 2026-07-23
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- UPDATE entity_links SET entity_type='contact' WHERE entity_type='company'
|
||||||
|
- UPDATE tag_assignments SET entity_type='contact' WHERE entity_type='company'
|
||||||
|
- UPDATE calendar_entry_links SET entity_type='contact' WHERE entity_type='company'
|
||||||
|
- UPDATE addresses SET entity_type='contact' WHERE entity_type='company'
|
||||||
|
- ALTER TABLE mails RENAME COLUMN company_id TO contact_id (if exists)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0027_unify_company_to_contact"
|
||||||
|
down_revision = "0026_mail_salt_security"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Update entity_links: company -> contact
|
||||||
|
op.execute(
|
||||||
|
"UPDATE entity_links SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||||
|
)
|
||||||
|
# Update tag_assignments: company -> contact
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tag_assignments SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||||
|
)
|
||||||
|
# Update calendar_entry_links: company -> contact
|
||||||
|
op.execute(
|
||||||
|
"UPDATE calendar_entry_links SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||||
|
)
|
||||||
|
# Update addresses: company -> contact
|
||||||
|
op.execute(
|
||||||
|
"UPDATE addresses SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||||
|
)
|
||||||
|
# Rename company_id to contact_id in mails (if column exists)
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.has_column(conn, "mails", "company_id"):
|
||||||
|
op.alter_column("mails", "company_id", new_column_name="contact_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Revert entity_links: contact -> company (only for rows that were originally company)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE entity_links SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||||
|
)
|
||||||
|
# Revert tag_assignments: contact -> company
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tag_assignments SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||||
|
)
|
||||||
|
# Revert calendar_entry_links: contact -> company
|
||||||
|
op.execute(
|
||||||
|
"UPDATE calendar_entry_links SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||||
|
)
|
||||||
|
# Revert addresses: contact -> company
|
||||||
|
op.execute(
|
||||||
|
"UPDATE addresses SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||||
|
)
|
||||||
|
# Rename contact_id back to company_id in mails
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.has_column(conn, "mails", "contact_id"):
|
||||||
|
op.alter_column("mails", "contact_id", new_column_name="company_id")
|
||||||
+30
-58
@@ -11,21 +11,17 @@ from typing import Any
|
|||||||
|
|
||||||
# Precompiled patterns for intent detection
|
# Precompiled patterns for intent detection
|
||||||
_PATTERNS = {
|
_PATTERNS = {
|
||||||
"create_company": re.compile(
|
"create_contact": re.compile(
|
||||||
r"\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b", re.IGNORECASE
|
r"\b(create|add|new)\b.*\b(contact|company|firm|organization|organisation|person)\b", re.IGNORECASE
|
||||||
),
|
),
|
||||||
"delete_company": re.compile(r"\b(delete|remove)\b.*\b(company|firm)\b", re.IGNORECASE),
|
"delete_contact": re.compile(r"\b(delete|remove)\b.*\b(contact|company|firm)\b", re.IGNORECASE),
|
||||||
"update_company": re.compile(
|
"update_contact": re.compile(
|
||||||
r"\b(update|edit|modify|change)\b.*\b(company|firm)\b", re.IGNORECASE
|
r"\b(update|edit|modify|change)\b.*\b(contact|company|firm)\b", re.IGNORECASE
|
||||||
),
|
),
|
||||||
"list_company": re.compile(
|
|
||||||
r"\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b", re.IGNORECASE
|
|
||||||
),
|
|
||||||
"list_company2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
|
|
||||||
"create_contact": re.compile(r"\b(create|add|new)\b.*\b(contact|person)\b", re.IGNORECASE),
|
|
||||||
"list_contact": re.compile(
|
"list_contact": re.compile(
|
||||||
r"\b(list|show|find|search|get|display)\b.*\b(contact|person)\b", re.IGNORECASE
|
r"\b(list|show|find|search|get|display)\b.*\b(contact|compan|firm|person)\b", re.IGNORECASE
|
||||||
),
|
),
|
||||||
|
"list_contact2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
|
||||||
"list_workflow": re.compile(r"\b(list|show|get|display)\b.*\b(workflow)\b", re.IGNORECASE),
|
"list_workflow": re.compile(r"\b(list|show|get|display)\b.*\b(workflow)\b", re.IGNORECASE),
|
||||||
"create_workflow": re.compile(r"\b(create|new|add)\b.*\b(workflow)\b", re.IGNORECASE),
|
"create_workflow": re.compile(r"\b(create|new|add)\b.*\b(workflow)\b", re.IGNORECASE),
|
||||||
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
||||||
@@ -34,8 +30,8 @@ _PATTERNS = {
|
|||||||
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||||
_NAME_PATTERNS = [
|
_NAME_PATTERNS = [
|
||||||
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
|
||||||
re.compile(r"\bcontact\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\bcontact\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
re.compile(r"\bworkflow\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\bworkflow\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
re.compile(r"\bfirm\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\bfirm\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
re.compile(r"\bperson\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\bperson\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
@@ -63,28 +59,28 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
|||||||
q = query.lower().strip()
|
q = query.lower().strip()
|
||||||
actions: list[dict[str, Any]] = []
|
actions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# --- Company intents ---
|
# --- Contact intents (unified: company + person) ---
|
||||||
if _PATTERNS["create_company"].search(q):
|
if _PATTERNS["create_contact"].search(q):
|
||||||
name = _extract_name(query)
|
name = _extract_name(query)
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/companies",
|
"path": "/api/v1/contacts",
|
||||||
"body": {"name": name or "New Company"},
|
"body": {"name": name or "New Contact", "type": "company"},
|
||||||
"description": f"Create a new company named '{name or 'New Company'}'",
|
"description": f"Create a new contact named '{name or 'New Contact'}'",
|
||||||
"confidence": 0.9,
|
"confidence": 0.9,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
elif _PATTERNS["delete_company"].search(q):
|
elif _PATTERNS["delete_contact"].search(q):
|
||||||
entity_id = context.get("company_id") or context.get("entity_id")
|
entity_id = context.get("contact_id") or context.get("entity_id")
|
||||||
if entity_id:
|
if entity_id:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": f"/api/v1/companies/{entity_id}",
|
"path": f"/api/v1/contacts/{entity_id}",
|
||||||
"body": None,
|
"body": None,
|
||||||
"description": f"Delete company {entity_id}",
|
"description": f"Delete contact {entity_id}",
|
||||||
"confidence": 0.9,
|
"confidence": 0.9,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -92,65 +88,41 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
|||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/companies/{id}",
|
"path": "/api/v1/contacts/{id}",
|
||||||
"body": None,
|
"body": None,
|
||||||
"description": "Delete a company (requires company ID in context or selection)",
|
"description": "Delete a contact (requires contact ID in context or selection)",
|
||||||
"confidence": 0.5,
|
"confidence": 0.5,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
elif _PATTERNS["update_company"].search(q):
|
elif _PATTERNS["update_contact"].search(q):
|
||||||
entity_id = context.get("company_id") or context.get("entity_id")
|
entity_id = context.get("contact_id") or context.get("entity_id")
|
||||||
path = f"/api/v1/companies/{entity_id}" if entity_id else "/api/v1/companies/{id}"
|
path = f"/api/v1/contacts/{entity_id}" if entity_id else "/api/v1/contacts/{id}"
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "PATCH",
|
"method": "PATCH",
|
||||||
"path": path,
|
"path": path,
|
||||||
"body": _extract_update_fields(query),
|
"body": _extract_update_fields(query),
|
||||||
"description": "Update company information",
|
"description": "Update contact information",
|
||||||
"confidence": 0.8,
|
"confidence": 0.8,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
elif _PATTERNS["list_company"].search(q) or _PATTERNS["list_company2"].search(q):
|
elif _PATTERNS["list_contact"].search(q) or _PATTERNS["list_contact2"].search(q):
|
||||||
search_term = _extract_search_term(query)
|
search_term = _extract_search_term(query)
|
||||||
desc = "List companies"
|
desc = "List contacts"
|
||||||
if search_term:
|
if search_term:
|
||||||
desc += f" matching '{search_term}'"
|
desc += f" matching '{search_term}'"
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/companies",
|
"path": "/api/v1/contacts",
|
||||||
"body": None,
|
"body": None,
|
||||||
"description": desc,
|
"description": desc,
|
||||||
"confidence": 0.85,
|
"confidence": 0.85,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Contact intents ---
|
|
||||||
elif _PATTERNS["create_contact"].search(q):
|
|
||||||
name = _extract_name(query)
|
|
||||||
actions.append(
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/contacts",
|
|
||||||
"body": {"name": name or "New Contact"},
|
|
||||||
"description": f"Create a new contact named '{name or 'New Contact'}'",
|
|
||||||
"confidence": 0.9,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif _PATTERNS["list_contact"].search(q):
|
|
||||||
actions.append(
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/contacts",
|
|
||||||
"body": None,
|
|
||||||
"description": "List contacts",
|
|
||||||
"confidence": 0.85,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Workflow intents ---
|
# --- Workflow intents ---
|
||||||
elif _PATTERNS["list_workflow"].search(q):
|
elif _PATTERNS["list_workflow"].search(q):
|
||||||
actions.append(
|
actions.append(
|
||||||
@@ -181,9 +153,9 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
|||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/companies",
|
"path": "/api/v1/contacts",
|
||||||
"body": None,
|
"body": None,
|
||||||
"description": "Show available companies (demonstration action)",
|
"description": "Show available contacts (demonstration action)",
|
||||||
"confidence": 0.3,
|
"confidence": 0.3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -222,9 +194,9 @@ def _extract_update_fields(query: str) -> dict[str, Any]:
|
|||||||
if re.search(r"\bphone\b", query, re.IGNORECASE):
|
if re.search(r"\bphone\b", query, re.IGNORECASE):
|
||||||
match = _PHONE_PAT.search(query)
|
match = _PHONE_PAT.search(query)
|
||||||
if match:
|
if match:
|
||||||
fields["phone"] = match.group(1).strip()
|
fields["phone_1"] = match.group(1).strip()
|
||||||
if re.search(r"\bemail\b", query, re.IGNORECASE):
|
if re.search(r"\bemail\b", query, re.IGNORECASE):
|
||||||
match = _EMAIL_PAT.search(query)
|
match = _EMAIL_PAT.search(query)
|
||||||
if match:
|
if match:
|
||||||
fields["email"] = match.group(1).strip()
|
fields["email_1"] = match.group(1).strip()
|
||||||
return fields or {"name": "Updated Name"}
|
return fields or {"name": "Updated Name"}
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Core system permissions ──
|
# ── Core system permissions ──
|
||||||
CORE_PERMISSIONS: list[dict[str, str]] = [
|
CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||||
{"key": "companies:read", "label": "Companies: Read", "category": "core", "module": "companies"},
|
|
||||||
{"key": "companies:write", "label": "Companies: Write", "category": "core", "module": "companies"},
|
|
||||||
{"key": "companies:delete", "label": "Companies: Delete", "category": "core", "module": "companies"},
|
|
||||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "core", "module": "contacts"},
|
{"key": "contacts:read", "label": "Contacts: Read", "category": "core", "module": "contacts"},
|
||||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "core", "module": "contacts"},
|
{"key": "contacts:write", "label": "Contacts: Write", "category": "core", "module": "contacts"},
|
||||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "core", "module": "contacts"},
|
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "core", "module": "contacts"},
|
||||||
@@ -61,21 +58,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
|||||||
|
|
||||||
# ── Core field definitions for field-level permissions ──
|
# ── Core field definitions for field-level permissions ──
|
||||||
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
||||||
{"module": "companies", "field": "name", "label": "Name", "sensitivity": "normal"},
|
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
|
||||||
{"module": "companies", "field": "account_number", "label": "Account Number", "sensitivity": "normal"},
|
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
|
||||||
{"module": "companies", "field": "industry", "label": "Industry", "sensitivity": "normal"},
|
{"module": "contacts", "field": "email_1", "label": "Email", "sensitivity": "normal"},
|
||||||
{"module": "companies", "field": "phone", "label": "Phone", "sensitivity": "normal"},
|
{"module": "contacts", "field": "phone_1", "label": "Phone", "sensitivity": "normal"},
|
||||||
{"module": "companies", "field": "email", "label": "Email", "sensitivity": "normal"},
|
{"module": "contacts", "field": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"},
|
||||||
{"module": "companies", "field": "website", "label": "Website", "sensitivity": "normal"},
|
{"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"},
|
||||||
{"module": "companies", "field": "description", "label": "Description", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "first_name", "label": "First Name", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "last_name", "label": "Last Name", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "email", "label": "Email", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "phone", "label": "Phone", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "mobile", "label": "Mobile", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "position", "label": "Position", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "department", "label": "Department", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "linkedin_url", "label": "LinkedIn URL", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
|
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
|
||||||
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||||
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Architecture:
|
|||||||
- Redis cache: resolved:{user_id}:{tenant_id} with 5-min TTL.
|
- Redis cache: resolved:{user_id}:{tenant_id} with 5-min TTL.
|
||||||
- Permission-version stamping for immediate invalidation.
|
- Permission-version stamping for immediate invalidation.
|
||||||
- Resolution: allowed = (role ∪ groups), denied = (role.denied ∪ groups.denied), resolved = allowed − denied.
|
- Resolution: allowed = (role ∪ groups), denied = (role.denied ∪ groups.denied), resolved = allowed − denied.
|
||||||
- Wildcards: companies:*, *:read, *:* (bare * is forbidden).
|
- Wildcards: contacts:*, *:read, *:* (bare * is forbidden).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,8 +35,8 @@ def _matches_permission(granted: str, required: str) -> bool:
|
|||||||
"""Check if a granted permission matches the required permission.
|
"""Check if a granted permission matches the required permission.
|
||||||
|
|
||||||
Supports wildcards:
|
Supports wildcards:
|
||||||
- companies:read → exact match
|
- contacts:read → exact match
|
||||||
- companies:* → all actions for companies
|
- contacts:* → all actions for contacts
|
||||||
- *:read → all modules, read action
|
- *:read → all modules, read action
|
||||||
- *:* → everything (superadmin)
|
- *:* → everything (superadmin)
|
||||||
"""
|
"""
|
||||||
@@ -67,9 +67,9 @@ def _normalize_permissions(permissions: Any) -> set[str]:
|
|||||||
"""Normalize permissions from JSONB to a set of strings.
|
"""Normalize permissions from JSONB to a set of strings.
|
||||||
|
|
||||||
Supports formats:
|
Supports formats:
|
||||||
- list[str]: ["companies:read", "contacts:write"]
|
- list[str]: ["contacts:read", "contacts:write"]
|
||||||
- dict[str, bool]: {"companies:read": true, "contacts:write": false}
|
- dict[str, bool]: {"contacts:read": true, "contacts:write": false}
|
||||||
- dict[str, dict]: {"companies": {"read": true, "write": false}}
|
- dict[str, dict]: {"contacts": {"read": true, "write": false}}
|
||||||
"""
|
"""
|
||||||
result: set[str] = set()
|
result: set[str] = set()
|
||||||
if isinstance(permissions, list):
|
if isinstance(permissions, list):
|
||||||
@@ -156,7 +156,7 @@ async def resolve_permissions(
|
|||||||
if legacy_role == "admin":
|
if legacy_role == "admin":
|
||||||
allowed.add("*:*")
|
allowed.add("*:*")
|
||||||
elif legacy_role == "editor":
|
elif legacy_role == "editor":
|
||||||
allowed |= {"companies:read", "companies:write", "contacts:read", "contacts:write",
|
allowed |= {"contacts:read", "contacts:write", "contacts:read", "contacts:write",
|
||||||
"users:read", "roles:read", "audit:read", "attachments:read",
|
"users:read", "roles:read", "audit:read", "attachments:read",
|
||||||
"attachments:write", "workflows:read", "workflows:write",
|
"attachments:write", "workflows:read", "workflows:write",
|
||||||
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
|
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
|
||||||
@@ -164,7 +164,7 @@ async def resolve_permissions(
|
|||||||
"notifications:read", "notifications:write", "import_export:read",
|
"notifications:read", "notifications:write", "import_export:read",
|
||||||
"import_export:write"}
|
"import_export:write"}
|
||||||
elif legacy_role == "viewer":
|
elif legacy_role == "viewer":
|
||||||
allowed |= {"companies:read", "contacts:read", "users:read", "roles:read",
|
allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read",
|
||||||
"audit:read", "attachments:read", "workflows:read", "sequences:read",
|
"audit:read", "attachments:read", "workflows:read", "sequences:read",
|
||||||
"addresses:read", "taxes:read", "currencies:read",
|
"addresses:read", "taxes:read", "currencies:read",
|
||||||
"notifications:read", "import_export:read"}
|
"notifications:read", "import_export:read"}
|
||||||
@@ -265,7 +265,7 @@ def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
resolved: result from get_cached_permissions or resolve_permissions
|
resolved: result from get_cached_permissions or resolve_permissions
|
||||||
required: permission string like "companies:read"
|
required: permission string like "contacts:read"
|
||||||
"""
|
"""
|
||||||
if resolved.get("is_system_admin"):
|
if resolved.get("is_system_admin"):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from app.plugins.builtins.unified_search.jobs import (
|
|||||||
index_mails,
|
index_mails,
|
||||||
index_file,
|
index_file,
|
||||||
index_contact,
|
index_contact,
|
||||||
index_company,
|
|
||||||
index_event,
|
index_event,
|
||||||
reindex,
|
reindex,
|
||||||
embedding_batch,
|
embedding_batch,
|
||||||
@@ -57,7 +56,6 @@ class WorkerSettings:
|
|||||||
index_mails,
|
index_mails,
|
||||||
index_file,
|
index_file,
|
||||||
index_contact,
|
index_contact,
|
||||||
index_company,
|
|
||||||
index_event,
|
index_event,
|
||||||
reindex,
|
reindex,
|
||||||
embedding_batch,
|
embedding_batch,
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ def require_permission(permission: str):
|
|||||||
"""FastAPI dependency factory: require a specific permission.
|
"""FastAPI dependency factory: require a specific permission.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@router.get("/companies", dependencies=[Depends(require_permission("companies:read"))])
|
@router.get("/contacts", dependencies=[Depends(require_permission("contacts:read"))])
|
||||||
"""
|
"""
|
||||||
async def _check(
|
async def _check(
|
||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from app.routes import (
|
|||||||
ai_copilot,
|
ai_copilot,
|
||||||
audit,
|
audit,
|
||||||
auth,
|
auth,
|
||||||
companies,
|
|
||||||
contact_folders,
|
contact_folders,
|
||||||
contacts,
|
contacts,
|
||||||
entity_history,
|
entity_history,
|
||||||
@@ -233,7 +232,6 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(groups.router)
|
app.include_router(groups.router)
|
||||||
app.include_router(tenants.router)
|
app.include_router(tenants.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(companies.router)
|
|
||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(contact_folders.router)
|
app.include_router(contact_folders.router)
|
||||||
app.include_router(entity_history.router)
|
app.include_router(entity_history.router)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from app.models.ai_conversation import AIConversation, AIMessage
|
|||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.audit import AuditLog, DeletionLog
|
from app.models.audit import AuditLog, DeletionLog
|
||||||
from app.models.auth import ApiToken, PasswordResetToken
|
from app.models.auth import ApiToken, PasswordResetToken
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.contact import Contact, ContactPerson
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.contact_folder import ContactFolder
|
from app.models.contact_folder import ContactFolder
|
||||||
from app.models.entity_history import EntityHistory
|
from app.models.entity_history import EntityHistory
|
||||||
@@ -37,7 +36,6 @@ __all__ = [
|
|||||||
"NotificationPreference",
|
"NotificationPreference",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
"ApiToken",
|
"ApiToken",
|
||||||
"Company",
|
|
||||||
"Contact",
|
"Contact",
|
||||||
"ContactPerson",
|
"ContactPerson",
|
||||||
"ContactFolder",
|
"ContactFolder",
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from app.core.db import Base, TenantMixin
|
|||||||
|
|
||||||
|
|
||||||
class Address(Base, TenantMixin):
|
class Address(Base, TenantMixin):
|
||||||
"""Polymorphic address entity — multiple addresses per company or contact.
|
"""Polymorphic address entity — multiple addresses per contact.
|
||||||
|
|
||||||
entity_type: 'company' or 'contact'
|
entity_type: 'contact'
|
||||||
entity_id: FK to companies.id or contacts.id
|
entity_id: FK to companies.id or contacts.id
|
||||||
address_type: billing, shipping, headquarters, branch, private, other
|
address_type: billing, shipping, headquarters, branch, private, other
|
||||||
is_default: one default address per (entity_type, entity_id, address_type)
|
is_default: one default address per (entity_type, entity_id, address_type)
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Backward-compat shim — Company is now Contact with type='company'.
|
|
||||||
|
|
||||||
This module re-exports Contact as Company for code that still imports
|
|
||||||
from app.models.company. The old companies table no longer exists;
|
|
||||||
all company data lives in the contacts table with type='company'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.models.contact import Contact, ContactPerson
|
|
||||||
|
|
||||||
# Backward-compat: Company is now just a Contact with type='company'
|
|
||||||
Company = Contact
|
|
||||||
|
|
||||||
__all__ = ["Company", "ContactPerson"]
|
|
||||||
@@ -142,7 +142,7 @@ async def deep_analysis(
|
|||||||
# All mails for company
|
# All mails for company
|
||||||
mail_result = await db.execute(
|
mail_result = await db.execute(
|
||||||
select(Mail)
|
select(Mail)
|
||||||
.where(Mail.company_id == eid)
|
.where(Mail.contact_id == eid)
|
||||||
.where(Mail.tenant_id == tid)
|
.where(Mail.tenant_id == tid)
|
||||||
.order_by(Mail.received_at.desc())
|
.order_by(Mail.received_at.desc())
|
||||||
.limit(30)
|
.limit(30)
|
||||||
|
|||||||
@@ -260,10 +260,10 @@ async def gather_context(
|
|||||||
)
|
)
|
||||||
context["thread"] = [_serialize_row(m) for m in thread_result.scalars().all()]
|
context["thread"] = [_serialize_row(m) for m in thread_result.scalars().all()]
|
||||||
|
|
||||||
if mail and mail.company_id:
|
if mail and mail.contact_id:
|
||||||
comp_result = await db.execute(
|
comp_result = await db.execute(
|
||||||
select(Company)
|
select(Company)
|
||||||
.where(Company.id == mail.company_id)
|
.where(Company.id == mail.contact_id)
|
||||||
.where(Company.tenant_id == tenant_id)
|
.where(Company.tenant_id == tenant_id)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -307,7 +307,7 @@ async def gather_context(
|
|||||||
|
|
||||||
mail_result = await db.execute(
|
mail_result = await db.execute(
|
||||||
select(Mail)
|
select(Mail)
|
||||||
.where(Mail.company_id == entity_id)
|
.where(Mail.contact_id == entity_id)
|
||||||
.where(Mail.tenant_id == tenant_id)
|
.where(Mail.tenant_id == tenant_id)
|
||||||
.order_by(Mail.received_at.desc())
|
.order_by(Mail.received_at.desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
@@ -321,7 +321,7 @@ async def gather_context(
|
|||||||
event_result = await db.execute(
|
event_result = await db.execute(
|
||||||
select(CalendarEntry)
|
select(CalendarEntry)
|
||||||
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
|
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
|
||||||
.where(CalendarEntryLink.entity_type == "company")
|
.where(CalendarEntryLink.entity_type == "contact")
|
||||||
.where(CalendarEntryLink.entity_id == entity_id)
|
.where(CalendarEntryLink.entity_id == entity_id)
|
||||||
.where(CalendarEntry.tenant_id == tenant_id)
|
.where(CalendarEntry.tenant_id == tenant_id)
|
||||||
.where(CalendarEntry.start_at > now)
|
.where(CalendarEntry.start_at > now)
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class EntryResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LinkRequest(BaseModel):
|
class LinkRequest(BaseModel):
|
||||||
entity_type: str = Field(..., pattern="^(company|contact)$")
|
entity_type: str = Field(..., pattern="^contact$")
|
||||||
entity_id: str
|
entity_id: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,13 @@ class EntityLinksPlugin(BasePlugin):
|
|||||||
module="app.plugins.builtins.entity_links.routes",
|
module="app.plugins.builtins.entity_links.routes",
|
||||||
router_attr="router",
|
router_attr="router",
|
||||||
),
|
),
|
||||||
PluginRouteDef(
|
|
||||||
path="/api/v1/companies",
|
|
||||||
module="app.plugins.builtins.entity_links.routes",
|
|
||||||
router_attr="company_router",
|
|
||||||
),
|
|
||||||
PluginRouteDef(
|
PluginRouteDef(
|
||||||
path="/api/v1/contacts",
|
path="/api/v1/contacts",
|
||||||
module="app.plugins.builtins.entity_links.routes",
|
module="app.plugins.builtins.entity_links.routes",
|
||||||
router_attr="contact_router",
|
router_attr="contact_router",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
events=["company.deleted", "contact.deleted"],
|
events=["contact.deleted"],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[
|
permissions=[
|
||||||
"entity_links:read",
|
"entity_links:read",
|
||||||
@@ -44,31 +39,6 @@ class EntityLinksPlugin(BasePlugin):
|
|||||||
is_core=True,
|
is_core=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
|
|
||||||
"""Handle company.deleted event — remove all EntityLink rows for that company."""
|
|
||||||
from sqlalchemy import delete
|
|
||||||
|
|
||||||
from app.core.db import get_session_factory
|
|
||||||
from app.plugins.builtins.entity_links.models import EntityLink
|
|
||||||
|
|
||||||
entity_id = payload.get("entity_id") or payload.get("company_id")
|
|
||||||
tenant_id = payload.get("tenant_id")
|
|
||||||
if entity_id is None or tenant_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import uuid as _uuid
|
|
||||||
|
|
||||||
factory = get_session_factory()
|
|
||||||
async with factory() as session:
|
|
||||||
await session.execute(
|
|
||||||
delete(EntityLink).where(
|
|
||||||
EntityLink.tenant_id == _uuid.UUID(tenant_id),
|
|
||||||
EntityLink.entity_type == "company",
|
|
||||||
EntityLink.entity_id == _uuid.UUID(str(entity_id)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
|
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
|
||||||
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
|
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ from app.plugins.builtins.entity_links.models import EntityLink
|
|||||||
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
|
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/dms", tags=["entity-links"])
|
router = APIRouter(prefix="/api/v1/dms", tags=["entity-links"])
|
||||||
company_router = APIRouter(prefix="/api/v1/companies", tags=["entity-links"])
|
|
||||||
contact_router = APIRouter(prefix="/api/v1/contacts", tags=["entity-links"])
|
contact_router = APIRouter(prefix="/api/v1/contacts", tags=["entity-links"])
|
||||||
|
|
||||||
VALID_ENTITY_TYPES = {"company", "contact"}
|
VALID_ENTITY_TYPES = {"contact"}
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||||
@@ -36,7 +35,7 @@ async def link_file_to_entity(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Link a file to a company or contact."""
|
"""Link a file to a contact."""
|
||||||
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"])
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
@@ -140,35 +139,6 @@ async def list_file_links(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@company_router.get("/{company_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
|
||||||
async def list_company_files(
|
|
||||||
company_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List all files linked to a company (reverse link)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
cid = _parse_uuid(company_id, "company_id")
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(EntityLink).where(
|
|
||||||
EntityLink.tenant_id == tenant_id,
|
|
||||||
EntityLink.entity_type == "company",
|
|
||||||
EntityLink.entity_id == cid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
links = result.scalars().all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(link.id),
|
|
||||||
"file_id": str(link.file_id),
|
|
||||||
"entity_type": link.entity_type,
|
|
||||||
"entity_id": str(link.entity_id),
|
|
||||||
}
|
|
||||||
for link in links
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
@contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||||
async def list_contact_files(
|
async def list_contact_files(
|
||||||
contact_id: str,
|
contact_id: str,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class EntityLinkRequest(BaseModel):
|
class EntityLinkRequest(BaseModel):
|
||||||
entity_type: str = Field(..., pattern="^(company|contact)$")
|
entity_type: str = Field(..., pattern="^contact$")
|
||||||
entity_id: str
|
entity_id: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ class Mail(Base, TenantMixin):
|
|||||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1451,13 +1451,10 @@ async def link_mail(
|
|||||||
await _check_delegate_access(db, account, user_id, "write")
|
await _check_delegate_access(db, account, user_id, "write")
|
||||||
if data.contact_id:
|
if data.contact_id:
|
||||||
mail.contact_id = _parse_uuid(data.contact_id, "contact_id")
|
mail.contact_id = _parse_uuid(data.contact_id, "contact_id")
|
||||||
if data.company_id:
|
|
||||||
mail.company_id = _parse_uuid(data.company_id, "company_id")
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {
|
return {
|
||||||
"linked": True,
|
"linked": True,
|
||||||
"contact_id": str(mail.contact_id) if mail.contact_id else None,
|
"contact_id": str(mail.contact_id) if mail.contact_id else None,
|
||||||
"company_id": str(mail.company_id) if mail.company_id else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ class MailResponse(BaseModel):
|
|||||||
received_at: datetime | None = None
|
received_at: datetime | None = None
|
||||||
sent_at: datetime | None = None
|
sent_at: datetime | None = None
|
||||||
contact_id: str | None = None
|
contact_id: str | None = None
|
||||||
company_id: str | None = None
|
|
||||||
attachments: list[dict] = Field(default_factory=list)
|
attachments: list[dict] = Field(default_factory=list)
|
||||||
labels: list[dict] = Field(default_factory=list)
|
labels: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -190,7 +189,6 @@ class MailListResponse(BaseModel):
|
|||||||
|
|
||||||
class MailLinkRequest(BaseModel):
|
class MailLinkRequest(BaseModel):
|
||||||
contact_id: str | None = None
|
contact_id: str | None = None
|
||||||
company_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MailMoveRequest(BaseModel):
|
class MailMoveRequest(BaseModel):
|
||||||
|
|||||||
@@ -2032,7 +2032,6 @@ def mail_to_response(
|
|||||||
"received_at": mail.received_at,
|
"received_at": mail.received_at,
|
||||||
"sent_at": mail.sent_at,
|
"sent_at": mail.sent_at,
|
||||||
"contact_id": str(mail.contact_id) if mail.contact_id else None,
|
"contact_id": str(mail.contact_id) if mail.contact_id else None,
|
||||||
"company_id": str(mail.company_id) if mail.company_id else None,
|
|
||||||
"attachments": [],
|
"attachments": [],
|
||||||
"labels": [],
|
"labels": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.plugins.builtins.tags.schemas import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
|
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
|
||||||
|
|
||||||
VALID_ENTITY_TYPES = {"company", "contact", "file", "folder"}
|
VALID_ENTITY_TYPES = {"contact", "file", "folder"}
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ class TagResponse(BaseModel):
|
|||||||
|
|
||||||
class TagAssignRequest(BaseModel):
|
class TagAssignRequest(BaseModel):
|
||||||
tag_id: str
|
tag_id: str
|
||||||
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
|
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
|
||||||
entity_id: str
|
entity_id: str
|
||||||
|
|
||||||
|
|
||||||
class TagUnassignRequest(BaseModel):
|
class TagUnassignRequest(BaseModel):
|
||||||
tag_id: str
|
tag_id: str
|
||||||
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
|
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
|
||||||
entity_id: str
|
entity_id: str
|
||||||
|
|
||||||
|
|
||||||
class TagBulkAssignRequest(BaseModel):
|
class TagBulkAssignRequest(BaseModel):
|
||||||
tag_ids: list[str] = Field(..., min_length=1)
|
tag_ids: list[str] = Field(..., min_length=1)
|
||||||
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
|
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
|
||||||
entity_id: str
|
entity_id: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class TestSamplePlugin(BasePlugin):
|
|||||||
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
||||||
dependencies=[],
|
dependencies=[],
|
||||||
routes=[],
|
routes=[],
|
||||||
events=["company.created", "contact.created"],
|
events=["contact.created"],
|
||||||
migrations=["0001_test_plugin.sql"],
|
migrations=["0001_test_plugin.sql"],
|
||||||
permissions=[],
|
permissions=[],
|
||||||
)
|
)
|
||||||
@@ -45,8 +45,5 @@ class TestSamplePlugin(BasePlugin):
|
|||||||
async def on_uninstall(self, db, service_container) -> None:
|
async def on_uninstall(self, db, service_container) -> None:
|
||||||
self.uninstall_called = True
|
self.uninstall_called = True
|
||||||
|
|
||||||
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
|
||||||
self.event_log.append({"event": "company.created", "payload": payload})
|
|
||||||
|
|
||||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||||
self.event_log.append({"event": "contact.created", "payload": payload})
|
self.event_log.append({"event": "contact.created", "payload": payload})
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ async def index_entity(
|
|||||||
|
|
||||||
table_map = {
|
table_map = {
|
||||||
"contact": "contacts",
|
"contact": "contacts",
|
||||||
"company": "contacts",
|
|
||||||
"mail": "mails",
|
"mail": "mails",
|
||||||
"file": "files",
|
"file": "files",
|
||||||
"event": "calendar_entries",
|
"event": "calendar_entries",
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def index_company(ctx: dict[str, Any], company_id: str) -> None:
|
async def index_company(ctx: dict[str, Any], company_id: str) -> None:
|
||||||
"""Index a company: generate and store embedding."""
|
"""Index a company (contact with type='company'): generate and store embedding."""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.plugins.builtins.unified_search.embedding import index_entity
|
from app.plugins.builtins.unified_search.embedding import index_entity
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ async def index_company(ctx: dict[str, Any], company_id: str) -> None:
|
|||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
await index_entity("company", eid, row["tenant_id"], db)
|
await index_entity("contact", eid, row["tenant_id"], db)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to index company %s", company_id)
|
logger.exception("Failed to index company %s", company_id)
|
||||||
|
|
||||||
@@ -170,7 +170,6 @@ async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
|
|||||||
|
|
||||||
table_map = {
|
table_map = {
|
||||||
"contact": "contacts",
|
"contact": "contacts",
|
||||||
"company": "contacts",
|
|
||||||
"mail": "mails",
|
"mail": "mails",
|
||||||
"file": "files",
|
"file": "files",
|
||||||
"event": "calendar_entries",
|
"event": "calendar_entries",
|
||||||
@@ -216,7 +215,6 @@ async def embedding_batch(ctx: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
table_map = {
|
table_map = {
|
||||||
"contact": "contacts",
|
"contact": "contacts",
|
||||||
"company": "contacts",
|
|
||||||
"mail": "mails",
|
"mail": "mails",
|
||||||
"file": "files",
|
"file": "files",
|
||||||
"event": "calendar_entries",
|
"event": "calendar_entries",
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ class UnifiedSearchPlugin(BasePlugin):
|
|||||||
"file.uploaded",
|
"file.uploaded",
|
||||||
"contact.created",
|
"contact.created",
|
||||||
"contact.updated",
|
"contact.updated",
|
||||||
"company.created",
|
|
||||||
"company.updated",
|
|
||||||
"calendar.entry.created",
|
"calendar.entry.created",
|
||||||
],
|
],
|
||||||
migrations=["0001_initial.sql", "0002_embeddings.sql"],
|
migrations=["0001_initial.sql", "0002_embeddings.sql"],
|
||||||
@@ -97,20 +95,6 @@ class UnifiedSearchPlugin(BasePlugin):
|
|||||||
if contact_id:
|
if contact_id:
|
||||||
await enqueue_job("index_contact", contact_id)
|
await enqueue_job("index_contact", contact_id)
|
||||||
|
|
||||||
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
|
||||||
from app.core.jobs import enqueue_job
|
|
||||||
|
|
||||||
company_id = payload.get("company_id")
|
|
||||||
if company_id:
|
|
||||||
await enqueue_job("index_company", company_id)
|
|
||||||
|
|
||||||
async def on_company_updated(self, payload: dict[str, Any]) -> None:
|
|
||||||
from app.core.jobs import enqueue_job
|
|
||||||
|
|
||||||
company_id = payload.get("company_id")
|
|
||||||
if company_id:
|
|
||||||
await enqueue_job("index_company", company_id)
|
|
||||||
|
|
||||||
async def on_calendar_entry_created(self, payload: dict[str, Any]) -> None:
|
async def on_calendar_entry_created(self, payload: dict[str, Any]) -> None:
|
||||||
from app.core.jobs import enqueue_job
|
from app.core.jobs import enqueue_job
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,6 @@ async def auto_register_providers(db: AsyncSession) -> None:
|
|||||||
from app.plugins.builtins.unified_search.providers.contact_provider import (
|
from app.plugins.builtins.unified_search.providers.contact_provider import (
|
||||||
ContactSearchProvider,
|
ContactSearchProvider,
|
||||||
)
|
)
|
||||||
from app.plugins.builtins.unified_search.providers.company_provider import (
|
|
||||||
CompanySearchProvider,
|
|
||||||
)
|
|
||||||
from app.plugins.builtins.unified_search.providers.mail_provider import (
|
from app.plugins.builtins.unified_search.providers.mail_provider import (
|
||||||
MailSearchProvider,
|
MailSearchProvider,
|
||||||
)
|
)
|
||||||
@@ -122,7 +119,6 @@ async def auto_register_providers(db: AsyncSession) -> None:
|
|||||||
# Register all built-in providers
|
# Register all built-in providers
|
||||||
for provider_cls in [
|
for provider_cls in [
|
||||||
ContactSearchProvider,
|
ContactSearchProvider,
|
||||||
CompanySearchProvider,
|
|
||||||
MailSearchProvider,
|
MailSearchProvider,
|
||||||
FileSearchProvider,
|
FileSearchProvider,
|
||||||
EventSearchProvider,
|
EventSearchProvider,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class CompanySearchProvider:
|
class CompanySearchProvider:
|
||||||
"""Search provider for Company entities (contacts with type='company')."""
|
"""Search provider for Company entities (contacts with type='company')."""
|
||||||
|
|
||||||
entity_type = "company"
|
entity_type = "contact"
|
||||||
|
|
||||||
async def search_fts(
|
async def search_fts(
|
||||||
self,
|
self,
|
||||||
@@ -117,5 +117,5 @@ class CompanySearchProvider:
|
|||||||
"title": name,
|
"title": name,
|
||||||
"snippet": email[:200],
|
"snippet": email[:200],
|
||||||
"score": 0.0,
|
"score": 0.0,
|
||||||
"data": {},
|
"data": {"type": "company"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ async def search(
|
|||||||
# Map entity_type to module name for field-level permissions
|
# Map entity_type to module name for field-level permissions
|
||||||
_ENTITY_TO_MODULE = {
|
_ENTITY_TO_MODULE = {
|
||||||
"contact": "contacts",
|
"contact": "contacts",
|
||||||
"company": "contacts",
|
|
||||||
"mail": "mail",
|
"mail": "mail",
|
||||||
"file": "dms",
|
"file": "dms",
|
||||||
"event": "calendar",
|
"event": "calendar",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# Entity type -> (table_name, tsv_column, embedding_column)
|
# Entity type -> (table_name, tsv_column, embedding_column)
|
||||||
SEARCHABLE_ENTITIES: dict[str, tuple[str, str, str]] = {
|
SEARCHABLE_ENTITIES: dict[str, tuple[str, str, str]] = {
|
||||||
"contact": ("contacts", "search_tsv", "embedding"),
|
"contact": ("contacts", "search_tsv", "embedding"),
|
||||||
"company": ("companies", "search_tsv", "embedding"),
|
|
||||||
"mail": ("mails", "body_tsv", "embedding"),
|
"mail": ("mails", "body_tsv", "embedding"),
|
||||||
"file": ("files", "content_tsv", "embedding"),
|
"file": ("files", "content_tsv", "embedding"),
|
||||||
"event": ("calendar_entries", "search_tsv", "embedding"),
|
"event": ("calendar_entries", "search_tsv", "embedding"),
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
|||||||
description="An example plugin demonstrating the manifest schema.",
|
description="An example plugin demonstrating the manifest schema.",
|
||||||
dependencies=[],
|
dependencies=[],
|
||||||
routes=[],
|
routes=[],
|
||||||
events=["company.created", "contact.created"],
|
events=["contact.created"],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=["companies.read"],
|
permissions=["contacts.read"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from app.routes import (
|
|||||||
ai_copilot, # noqa: F401
|
ai_copilot, # noqa: F401
|
||||||
audit, # noqa: F401
|
audit, # noqa: F401
|
||||||
auth, # noqa: F401
|
auth, # noqa: F401
|
||||||
companies, # noqa: F401
|
|
||||||
contacts, # noqa: F401
|
contacts, # noqa: F401
|
||||||
entity_history, # noqa: F401
|
entity_history, # noqa: F401
|
||||||
currencies, # noqa: F401
|
currencies, # noqa: F401
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
"""Company routes — full CRUD, FTS search, filter, pagination, export, N:M, emails."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.db import get_db
|
|
||||||
from app.deps import require_permission
|
|
||||||
from app.schemas.company import CompanyCreate, CompanyUpdate
|
|
||||||
from app.services import company_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_companies(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
search: str | None = Query(None),
|
|
||||||
industry: str | None = Query(None),
|
|
||||||
sort_by: str = Query("name"),
|
|
||||||
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:read")),
|
|
||||||
):
|
|
||||||
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
result = await company_service.list_companies(
|
|
||||||
db,
|
|
||||||
tenant_id,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
search=search,
|
|
||||||
industry=industry,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_order=sort_order,
|
|
||||||
resolved_perms=current_user,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_company(
|
|
||||||
body: CompanyCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:write")),
|
|
||||||
):
|
|
||||||
"""Create a company. Requires write permission."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
data = body.model_dump()
|
|
||||||
return await company_service.create_company(db, tenant_id, user_id, data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export")
|
|
||||||
async def export_companies(
|
|
||||||
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
|
||||||
industry: str | None = Query(None),
|
|
||||||
search: str | None = Query(None),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:read")),
|
|
||||||
):
|
|
||||||
"""Export companies as CSV (streaming) or XLSX (buffered)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
|
|
||||||
if format == "csv":
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
|
|
||||||
from app.core.db import get_session_factory
|
|
||||||
from app.models.company import Company
|
|
||||||
|
|
||||||
async def generate_csv():
|
|
||||||
"""Async generator yielding CSV rows (streaming, not buffered).
|
|
||||||
Creates its own DB session to avoid request-scoped session issues.
|
|
||||||
"""
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
header = [
|
|
||||||
"id", "name", "account_number", "industry", "phone",
|
|
||||||
"email", "website", "description", "created_at", "updated_at",
|
|
||||||
]
|
|
||||||
writer.writerow(header)
|
|
||||||
yield output.getvalue()
|
|
||||||
output.seek(0)
|
|
||||||
output.truncate(0)
|
|
||||||
|
|
||||||
batch_size = 500
|
|
||||||
offset = 0
|
|
||||||
factory = get_session_factory()
|
|
||||||
async with factory() as session:
|
|
||||||
while True:
|
|
||||||
q = sa_select(Company).where(
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
if industry:
|
|
||||||
q = q.where(Company.industry == industry)
|
|
||||||
if search:
|
|
||||||
pattern = f"%{search}%"
|
|
||||||
q = q.where(Company.name.ilike(pattern))
|
|
||||||
q = q.order_by(Company.name.asc()).offset(offset).limit(batch_size)
|
|
||||||
result = await session.execute(q)
|
|
||||||
companies = result.scalars().all()
|
|
||||||
if not companies:
|
|
||||||
break
|
|
||||||
for c in companies:
|
|
||||||
writer.writerow([
|
|
||||||
str(c.id), c.name, c.account_number or "", c.industry or "",
|
|
||||||
c.phone or "", c.email or "", c.website or "",
|
|
||||||
c.description or "",
|
|
||||||
c.created_at.isoformat() if c.created_at else "",
|
|
||||||
c.updated_at.isoformat() if c.updated_at else "",
|
|
||||||
])
|
|
||||||
yield output.getvalue()
|
|
||||||
output.seek(0)
|
|
||||||
output.truncate(0)
|
|
||||||
offset += batch_size
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
generate_csv(),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": "attachment; filename=companies.csv"},
|
|
||||||
)
|
|
||||||
elif format == "xlsx":
|
|
||||||
xlsx_data = await company_service.export_companies_xlsx(
|
|
||||||
db,
|
|
||||||
tenant_id,
|
|
||||||
industry=industry,
|
|
||||||
search=search,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
content=xlsx_data,
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
headers={"Content-Disposition": "attachment; filename=companies.xlsx"},
|
|
||||||
)
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid format", "code": "invalid_format"})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{company_id}")
|
|
||||||
async def get_company(
|
|
||||||
company_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:read")),
|
|
||||||
):
|
|
||||||
"""Get a single company with contacts array. Cross-tenant returns 404."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
try:
|
|
||||||
cid = uuid.UUID(company_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
data = await company_service.get_company_detail(db, tenant_id, cid, resolved_perms=current_user)
|
|
||||||
if data is None:
|
|
||||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{company_id}")
|
|
||||||
async def update_company(
|
|
||||||
company_id: str,
|
|
||||||
body: CompanyUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:write")),
|
|
||||||
):
|
|
||||||
"""Update a company (full PUT)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
cid = uuid.UUID(company_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
|
||||||
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{company_id}")
|
|
||||||
async def delete_company(
|
|
||||||
company_id: str,
|
|
||||||
cascade: bool = Query(False),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:write")),
|
|
||||||
):
|
|
||||||
"""Soft-delete a company. Use cascade=true to also delete N:M links."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
cid = uuid.UUID(company_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
deleted = await company_service.soft_delete_company(
|
|
||||||
db, tenant_id, user_id, cid, cascade=cascade
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
|
||||||
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{company_id}/contacts/{contact_id}")
|
|
||||||
async def link_company_contact(
|
|
||||||
company_id: str,
|
|
||||||
contact_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:write")),
|
|
||||||
):
|
|
||||||
"""Link a contact to a company (N:M)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
comp_id = uuid.UUID(company_id)
|
|
||||||
cont_id = uuid.UUID(contact_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
|
||||||
|
|
||||||
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
404, detail={"detail": "Company or contact not found", "code": "not_found"}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{company_id}/contacts/{contact_id}")
|
|
||||||
async def unlink_company_contact(
|
|
||||||
company_id: str,
|
|
||||||
contact_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:write")),
|
|
||||||
):
|
|
||||||
"""Unlink a contact from a company (N:M)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
comp_id = uuid.UUID(company_id)
|
|
||||||
cont_id = uuid.UUID(contact_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
|
||||||
|
|
||||||
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
|
|
||||||
if not unlinked:
|
|
||||||
raise HTTPException(404, detail={"detail": "Link not found", "code": "not_found"})
|
|
||||||
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{company_id}/emails")
|
|
||||||
async def get_company_emails(
|
|
||||||
company_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: dict = Depends(require_permission("companies:read")),
|
|
||||||
):
|
|
||||||
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
||||||
try:
|
|
||||||
cid = uuid.UUID(company_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
# Verify company exists
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.models.company import Company
|
|
||||||
|
|
||||||
q = select(Company).where(
|
|
||||||
Company.id == cid,
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
result = await db.execute(q)
|
|
||||||
if result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
|
||||||
|
|
||||||
return []
|
|
||||||
@@ -24,9 +24,6 @@ router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
|
|||||||
|
|
||||||
|
|
||||||
SYSTEM_PERMISSIONS: list[dict[str, str]] = [
|
SYSTEM_PERMISSIONS: list[dict[str, str]] = [
|
||||||
{"key": "companies:read", "label": "Companies: Read", "category": "system"},
|
|
||||||
{"key": "companies:write", "label": "Companies: Write", "category": "system"},
|
|
||||||
{"key": "companies:delete", "label": "Companies: Delete", "category": "system"},
|
|
||||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "system"},
|
{"key": "contacts:read", "label": "Contacts: Read", "category": "system"},
|
||||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "system"},
|
{"key": "contacts:write", "label": "Contacts: Write", "category": "system"},
|
||||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "system"},
|
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "system"},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class AddressCreate(BaseModel):
|
class AddressCreate(BaseModel):
|
||||||
entity_type: str = Field(..., pattern="^(company|contact)$", description="'company' or 'contact'")
|
entity_type: str = Field(..., pattern="^contact$", description="'contact'")
|
||||||
entity_id: str = Field(..., description="UUID of the parent entity")
|
entity_id: str = Field(..., description="UUID of the parent entity")
|
||||||
label: str = Field(..., min_length=1, max_length=100)
|
label: str = Field(..., min_length=1, max_length=100)
|
||||||
address_type: str = Field(
|
address_type: str = Field(
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Company schemas — create, update, read, list, pagination, search, filter."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
account_number: str | None = Field(None, max_length=40)
|
|
||||||
industry: str | None = Field(None, max_length=50)
|
|
||||||
phone: str | None = Field(None, max_length=30)
|
|
||||||
email: str | None = Field(None, max_length=255)
|
|
||||||
website: str | None = Field(None, max_length=500)
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyUpdate(BaseModel):
|
|
||||||
name: str | None = Field(None, min_length=1, max_length=100)
|
|
||||||
account_number: str | None = Field(None, max_length=40)
|
|
||||||
industry: str | None = Field(None, max_length=50)
|
|
||||||
phone: str | None = Field(None, max_length=30)
|
|
||||||
email: str | None = Field(None, max_length=255)
|
|
||||||
website: str | None = Field(None, max_length=500)
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
account_number: str | None = None
|
|
||||||
industry: str | None = None
|
|
||||||
phone: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
website: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyDetailResponse(CompanyResponse):
|
|
||||||
contacts: list[dict] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyListResponse(BaseModel):
|
|
||||||
items: list[CompanyResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyExportRequest(BaseModel):
|
|
||||||
format: str = Field("csv", pattern="^(csv|xlsx)$")
|
|
||||||
industry: str | None = None
|
|
||||||
search: str | None = None
|
|
||||||
@@ -13,7 +13,7 @@ from app.core.audit import log_audit
|
|||||||
from app.models.address import Address
|
from app.models.address import Address
|
||||||
|
|
||||||
|
|
||||||
VALID_ENTITY_TYPES = {"company", "contact"}
|
VALID_ENTITY_TYPES = {"contact"}
|
||||||
VALID_ADDRESS_TYPES = {"billing", "shipping", "headquarters", "branch", "private", "other"}
|
VALID_ADDRESS_TYPES = {"billing", "shipping", "headquarters", "branch", "private", "other"}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
|||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(Address).where(
|
select(Address).where(
|
||||||
Address.tenant_id == company.tenant_id,
|
Address.tenant_id == company.tenant_id,
|
||||||
Address.entity_type == "company",
|
Address.entity_type == "contact",
|
||||||
Address.entity_id == company.id,
|
Address.entity_id == company.id,
|
||||||
Address.address_type == "headquarters",
|
Address.address_type == "headquarters",
|
||||||
Address.deleted_at.is_(None),
|
Address.deleted_at.is_(None),
|
||||||
@@ -217,7 +217,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
|||||||
|
|
||||||
addr = Address(
|
addr = Address(
|
||||||
tenant_id=company.tenant_id,
|
tenant_id=company.tenant_id,
|
||||||
entity_type="company",
|
entity_type="contact",
|
||||||
entity_id=company.id,
|
entity_id=company.id,
|
||||||
label="Hauptsitz",
|
label="Hauptsitz",
|
||||||
address_type="headquarters",
|
address_type="headquarters",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client
|
|||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.auth import check_permission
|
from app.core.auth import check_permission
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
from app.models.contact import Contact as Company
|
from app.models.contact import Contact
|
||||||
from app.models.contact import Contact
|
from app.models.contact import Contact
|
||||||
from app.models.workflow import Workflow
|
from app.models.workflow import Workflow
|
||||||
|
|
||||||
@@ -306,16 +306,14 @@ async def _execute_api_action(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute an API action directly against the database.
|
"""Execute an API action directly against the database.
|
||||||
|
|
||||||
Supports companies and contacts CRUD, plus workflow listing.
|
Supports contacts CRUD (including company-type contacts), plus workflow listing.
|
||||||
"""
|
"""
|
||||||
# Parse path to determine entity and operation
|
# Parse path to determine entity and operation
|
||||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||||
entity = parts[0] if parts else ""
|
entity = parts[0] if parts else ""
|
||||||
entity_id = parts[1] if len(parts) > 1 else None
|
entity_id = parts[1] if len(parts) > 1 else None
|
||||||
|
|
||||||
if entity == "companies":
|
if entity in ("companies", "contacts"):
|
||||||
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
|
|
||||||
elif entity == "contacts":
|
|
||||||
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
||||||
elif entity == "workflows":
|
elif entity == "workflows":
|
||||||
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
||||||
@@ -323,7 +321,7 @@ async def _execute_api_action(
|
|||||||
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
||||||
|
|
||||||
|
|
||||||
async def _exec_companies(
|
async def _exec_contacts\(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
@@ -331,99 +329,7 @@ async def _exec_companies(
|
|||||||
entity_id: str | None,
|
entity_id: str | None,
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute company operations."""
|
"""Execute contact operations (unified: company + person)."""
|
||||||
if method == "GET":
|
|
||||||
result = await db.execute(
|
|
||||||
select(Company).where(
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
companies = result.scalars().all()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"status_code": 200,
|
|
||||||
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
|
|
||||||
}
|
|
||||||
|
|
||||||
elif method == "POST":
|
|
||||||
company = Company(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
name=body.get("name", "Untitled"),
|
|
||||||
industry=body.get("industry"),
|
|
||||||
phone=body.get("phone"),
|
|
||||||
email=body.get("email"),
|
|
||||||
website=body.get("website"),
|
|
||||||
description=body.get("description"),
|
|
||||||
created_by=user_id,
|
|
||||||
updated_by=user_id,
|
|
||||||
)
|
|
||||||
db.add(company)
|
|
||||||
await db.flush()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"status_code": 201,
|
|
||||||
"data": {"id": str(company.id), "name": company.name},
|
|
||||||
}
|
|
||||||
|
|
||||||
elif method == "DELETE":
|
|
||||||
if not entity_id or entity_id == "{id}":
|
|
||||||
return {"error": "Company ID required", "status_code": 400, "success": False}
|
|
||||||
comp_uuid = uuid.UUID(entity_id)
|
|
||||||
result = await db.execute(
|
|
||||||
select(Company).where(
|
|
||||||
Company.id == comp_uuid,
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
company = result.scalar_one_or_none()
|
|
||||||
if company is None:
|
|
||||||
return {"error": "Company not found", "status_code": 404, "success": False}
|
|
||||||
company.deleted_at = datetime.now(UTC)
|
|
||||||
await db.flush()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"status_code": 200,
|
|
||||||
"data": {"id": str(company.id), "deleted": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
elif method == "PATCH":
|
|
||||||
if not entity_id or entity_id == "{id}":
|
|
||||||
return {"error": "Company ID required", "status_code": 400, "success": False}
|
|
||||||
comp_uuid = uuid.UUID(entity_id)
|
|
||||||
result = await db.execute(
|
|
||||||
select(Company).where(
|
|
||||||
Company.id == comp_uuid,
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
company = result.scalar_one_or_none()
|
|
||||||
if company is None:
|
|
||||||
return {"error": "Company not found", "status_code": 404, "success": False}
|
|
||||||
for key in ("name", "industry", "phone", "email", "website", "description"):
|
|
||||||
if key in body:
|
|
||||||
setattr(company, key, body[key])
|
|
||||||
company.updated_by = user_id
|
|
||||||
await db.flush()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"status_code": 200,
|
|
||||||
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
|
||||||
|
|
||||||
|
|
||||||
async def _exec_contacts(
|
|
||||||
db: AsyncSession,
|
|
||||||
tenant_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
method: str,
|
|
||||||
entity_id: str | None,
|
|
||||||
body: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Execute contact operations."""
|
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Contact).where(
|
select(Contact).where(
|
||||||
@@ -435,15 +341,16 @@ async def _exec_contacts(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"status_code": 200,
|
"status_code": 200,
|
||||||
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
|
"data": [{"id": str(c.id), "name": c.name or c.displayname, "email": c.email_1, "type": c.type} for c in contacts],
|
||||||
}
|
}
|
||||||
|
|
||||||
elif method == "POST":
|
elif method == "POST":
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
type=body.get("type", "company"),
|
||||||
name=body.get("name", "Untitled"),
|
name=body.get("name", "Untitled"),
|
||||||
email=body.get("email"),
|
email_1=body.get("email"),
|
||||||
phone=body.get("phone"),
|
phone_1=body.get("phone"),
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
)
|
)
|
||||||
@@ -452,7 +359,7 @@ async def _exec_contacts(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"status_code": 201,
|
"status_code": 201,
|
||||||
"data": {"id": str(contact.id), "name": contact.name},
|
"data": {"id": str(contact.id), "name": contact.name, "type": contact.type},
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
"""Company service — now operates on Contact with type='company'.
|
|
||||||
|
|
||||||
Backward-compat layer: all company operations are now contact operations
|
|
||||||
filtered by type='company'. CompanyContact is replaced by ContactPerson 1:N.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import select, func, delete
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.models.contact import Contact, ContactPerson
|
|
||||||
|
|
||||||
|
|
||||||
def _company_to_dict(c: Contact) -> dict:
|
|
||||||
"""Serialize a company (Contact with type=company) to dict."""
|
|
||||||
return {
|
|
||||||
"id": str(c.id),
|
|
||||||
"name": c.name or "",
|
|
||||||
"account_number": c.code or "",
|
|
||||||
"industry": (c.custom or {}).get("industry") if c.custom else None,
|
|
||||||
"phone": c.phone_1,
|
|
||||||
"email": c.email_1,
|
|
||||||
"website": c.website,
|
|
||||||
"description": (c.custom or {}).get("description") if c.custom else None,
|
|
||||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
|
||||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def list_companies(
|
|
||||||
db: AsyncSession,
|
|
||||||
tenant_id: uuid.UUID,
|
|
||||||
page: int = 1,
|
|
||||||
page_size: int = 20,
|
|
||||||
search: str | None = None,
|
|
||||||
industry: str | None = None,
|
|
||||||
sort_by: str = "name",
|
|
||||||
sort_order: str = "asc",
|
|
||||||
resolved_perms: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""List companies (contacts with type='company')."""
|
|
||||||
base = select(Contact).where(
|
|
||||||
Contact.tenant_id == tenant_id,
|
|
||||||
Contact.type == "company",
|
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
|
||||||
|
|
||||||
count_q = select(func.count()).select_from(base.subquery())
|
|
||||||
total = (await db.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
sort_col = getattr(Contact, sort_by if sort_by != "name" else "name", Contact.name)
|
|
||||||
if sort_order == "desc":
|
|
||||||
sort_col = sort_col.desc()
|
|
||||||
base = base.order_by(sort_col)
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
base = base.offset(offset).limit(page_size)
|
|
||||||
|
|
||||||
result = await db.execute(base)
|
|
||||||
companies = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"items": [_company_to_dict(c) for c in companies],
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def create_company(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
|
||||||
) -> dict:
|
|
||||||
"""Create a company (contact with type='company')."""
|
|
||||||
# Map old fields to new
|
|
||||||
contact_data = {
|
|
||||||
"type": "company",
|
|
||||||
"name": data.get("name", ""),
|
|
||||||
"code": data.get("account_number"),
|
|
||||||
"phone_1": data.get("phone"),
|
|
||||||
"email_1": data.get("email"),
|
|
||||||
"website": data.get("website"),
|
|
||||||
"displayname": data.get("name", ""),
|
|
||||||
}
|
|
||||||
# Store industry/description in custom
|
|
||||||
custom = {}
|
|
||||||
if data.get("industry"):
|
|
||||||
custom["industry"] = data["industry"]
|
|
||||||
if data.get("description"):
|
|
||||||
custom["description"] = data["description"]
|
|
||||||
if custom:
|
|
||||||
contact_data["custom"] = custom
|
|
||||||
|
|
||||||
contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
|
|
||||||
db.add(contact)
|
|
||||||
await db.flush()
|
|
||||||
return _company_to_dict(contact)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_company_detail(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, resolved_perms: dict | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""Get company detail with contact persons."""
|
|
||||||
q = (
|
|
||||||
select(Contact)
|
|
||||||
.options(selectinload(Contact.contact_persons))
|
|
||||||
.where(
|
|
||||||
Contact.id == uuid.UUID(company_id),
|
|
||||||
Contact.tenant_id == tenant_id,
|
|
||||||
Contact.type == "company",
|
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
result = await db.execute(q)
|
|
||||||
contact = result.scalar_one_or_none()
|
|
||||||
if not contact:
|
|
||||||
raise ValueError("Company not found")
|
|
||||||
|
|
||||||
data = _company_to_dict(contact)
|
|
||||||
data["contacts"] = [
|
|
||||||
{
|
|
||||||
"id": str(cp.id),
|
|
||||||
"first_name": cp.firstname or "",
|
|
||||||
"last_name": cp.lastname or "",
|
|
||||||
"email": cp.email,
|
|
||||||
"phone": cp.phone,
|
|
||||||
"position": cp.function,
|
|
||||||
}
|
|
||||||
for cp in (contact.contact_persons or [])
|
|
||||||
if cp.deleted_at is None
|
|
||||||
]
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def update_company(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
|
|
||||||
) -> dict:
|
|
||||||
"""Update a company."""
|
|
||||||
q = select(Contact).where(
|
|
||||||
Contact.id == uuid.UUID(company_id),
|
|
||||||
Contact.tenant_id == tenant_id,
|
|
||||||
Contact.type == "company",
|
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
result = await db.execute(q)
|
|
||||||
contact = result.scalar_one_or_none()
|
|
||||||
if not contact:
|
|
||||||
raise ValueError("Company not found")
|
|
||||||
|
|
||||||
if "name" in data:
|
|
||||||
contact.name = data["name"]
|
|
||||||
contact.displayname = data["name"]
|
|
||||||
if "account_number" in data:
|
|
||||||
contact.code = data["account_number"]
|
|
||||||
if "phone" in data:
|
|
||||||
contact.phone_1 = data["phone"]
|
|
||||||
if "email" in data:
|
|
||||||
contact.email_1 = data["email"]
|
|
||||||
if "website" in data:
|
|
||||||
contact.website = data["website"]
|
|
||||||
|
|
||||||
# Store industry/description in custom
|
|
||||||
custom = dict(contact.custom or {})
|
|
||||||
if "industry" in data:
|
|
||||||
custom["industry"] = data["industry"]
|
|
||||||
if "description" in data:
|
|
||||||
custom["description"] = data["description"]
|
|
||||||
if custom:
|
|
||||||
contact.custom = custom
|
|
||||||
|
|
||||||
contact.updated_by = user_id
|
|
||||||
await db.flush()
|
|
||||||
return _company_to_dict(contact)
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_company(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
|
|
||||||
) -> bool:
|
|
||||||
"""Soft-delete a company."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
q = select(Contact).where(
|
|
||||||
Contact.id == uuid.UUID(company_id),
|
|
||||||
Contact.tenant_id == tenant_id,
|
|
||||||
Contact.type == "company",
|
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
result = await db.execute(q)
|
|
||||||
contact = result.scalar_one_or_none()
|
|
||||||
if not contact:
|
|
||||||
return False
|
|
||||||
contact.deleted_at = datetime.now(timezone.utc)
|
|
||||||
await db.flush()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def export_companies_xlsx(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
|
|
||||||
) -> bytes:
|
|
||||||
"""Export companies as XLSX."""
|
|
||||||
from openpyxl import Workbook
|
|
||||||
|
|
||||||
base = select(Contact).where(
|
|
||||||
Contact.tenant_id == tenant_id,
|
|
||||||
Contact.type == "company",
|
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
if search:
|
|
||||||
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
|
||||||
base = base.order_by(Contact.name)
|
|
||||||
|
|
||||||
result = await db.execute(base)
|
|
||||||
companies = result.scalars().all()
|
|
||||||
|
|
||||||
wb = Workbook()
|
|
||||||
ws = wb.active
|
|
||||||
ws.title = "Companies"
|
|
||||||
ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
|
|
||||||
for c in companies:
|
|
||||||
custom = c.custom or {}
|
|
||||||
ws.append([
|
|
||||||
c.name or "", c.code or "", custom.get("industry", ""),
|
|
||||||
c.phone_1 or "", c.email_1 or "", c.website or "",
|
|
||||||
])
|
|
||||||
|
|
||||||
import io
|
|
||||||
buf = io.BytesIO()
|
|
||||||
wb.save(buf)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
async def link_contact(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
|
||||||
) -> dict:
|
|
||||||
"""Link a contact person to a company (create ContactPerson)."""
|
|
||||||
# In new model, contact_id is used to create a ContactPerson under the company contact
|
|
||||||
# This is a compat shim — the old API expected a separate Contact entity
|
|
||||||
# Now we just create a ContactPerson linked to the company
|
|
||||||
cp = ContactPerson(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
contact_id=uuid.UUID(company_id),
|
|
||||||
created_by=user_id,
|
|
||||||
updated_by=user_id,
|
|
||||||
)
|
|
||||||
db.add(cp)
|
|
||||||
await db.flush()
|
|
||||||
return {"id": str(cp.id), "company_id": company_id, "contact_id": contact_id}
|
|
||||||
|
|
||||||
|
|
||||||
async def unlink_contact(
|
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
|
||||||
) -> bool:
|
|
||||||
"""Unlink a contact person from a company (soft-delete ContactPerson)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
q = select(ContactPerson).where(
|
|
||||||
ContactPerson.contact_id == uuid.UUID(company_id),
|
|
||||||
ContactPerson.id == uuid.UUID(contact_id),
|
|
||||||
ContactPerson.tenant_id == tenant_id,
|
|
||||||
ContactPerson.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
result = await db.execute(q)
|
|
||||||
cp = result.scalar_one_or_none()
|
|
||||||
if not cp:
|
|
||||||
return False
|
|
||||||
cp.deleted_at = datetime.now(timezone.utc)
|
|
||||||
await db.flush()
|
|
||||||
return True
|
|
||||||
@@ -302,5 +302,5 @@ def register_workflow_event_handlers() -> None:
|
|||||||
await handle_event(db, tenant_id, event_name, payload)
|
await handle_event(db, tenant_id, event_name, payload)
|
||||||
|
|
||||||
# Subscribe to common events
|
# Subscribe to common events
|
||||||
for event_name in ("user.created", "company.created", "contact.created"):
|
for event_name in ("user.created", "contact.created"):
|
||||||
event_bus.subscribe(event_name, _workflow_event_handler)
|
event_bus.subscribe(event_name, _workflow_event_handler)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults';
|
|||||||
vi.mock('@/api/hooks', () => ({
|
vi.mock('@/api/hooks', () => ({
|
||||||
useGlobalSearch: () => ({
|
useGlobalSearch: () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' },
|
{ id: '1', type: 'contact', name: 'TestCorp GmbH', description: 'IT company', url: '/contacts/1' },
|
||||||
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
|
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
|
||||||
],
|
],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
vi.mock('@/api/hooks', () => ({
|
vi.mock('@/api/hooks', () => ({
|
||||||
useGlobalSearch: () => ({
|
useGlobalSearch: () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' },
|
{ id: '1', type: 'contact', name: 'TestCorp GmbH', description: 'IT company', url: '/contacts/1' },
|
||||||
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
|
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
|
||||||
{ id: '3', type: 'mail', name: 'Test Email', description: 'Subject: Hello', url: '/mail/3' },
|
{ id: '3', type: 'mail', name: 'Test Email', description: 'Subject: Hello', url: '/mail/3' },
|
||||||
{ id: '4', type: 'file', name: 'Document.pdf', description: 'PDF file', url: '/dms/4' },
|
{ id: '4', type: 'file', name: 'Document.pdf', description: 'PDF file', url: '/dms/4' },
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('BulkTagDialog', () => {
|
describe('BulkTagDialog', () => {
|
||||||
it('renders dialog when open', async () => {
|
it('renders dialog when open', async () => {
|
||||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows selected entity count', async () => {
|
it('shows selected entity count', async () => {
|
||||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument();
|
expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads and displays available tags', async () => {
|
it('loads and displays available tags', async () => {
|
||||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchTags).toHaveBeenCalled();
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe('BulkTagDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('toggles tag selection on click', async () => {
|
it('toggles tag selection on click', async () => {
|
||||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchTags).toHaveBeenCalled();
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,7 @@ describe('BulkTagDialog', () => {
|
|||||||
it('calls bulkAssignTags on assign button click', async () => {
|
it('calls bulkAssignTags on assign button click', async () => {
|
||||||
const onAssigned = vi.fn();
|
const onAssigned = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchTags).toHaveBeenCalled();
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -73,14 +73,14 @@ describe('BulkTagDialog', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(bulkAssignTags).toHaveBeenCalledWith({
|
expect(bulkAssignTags).toHaveBeenCalledWith({
|
||||||
tag_ids: ['t1'],
|
tag_ids: ['t1'],
|
||||||
entity_type: 'company',
|
entity_type: 'contact',
|
||||||
entity_ids: ['c1', 'c2'],
|
entity_ids: ['c1', 'c2'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render when closed', () => {
|
it('does not render when closed', () => {
|
||||||
render(<BulkTagDialog open={false} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
render(<BulkTagDialog open={false} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { TagPicker } from '@/components/tags/TagPicker';
|
|
||||||
|
|
||||||
const mockTags = [
|
|
||||||
{ id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 },
|
|
||||||
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 },
|
|
||||||
{ id: 't3', name: 'Aktiv', color: '#10B981', created_by: 'u1', usage_count: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFetchTags = vi.fn();
|
const mockFetchTags = vi.fn();
|
||||||
const mockAssignTag = vi.fn();
|
const mockAssignTag = vi.fn();
|
||||||
@@ -15,44 +8,48 @@ const mockUnassignTag = vi.fn();
|
|||||||
const mockCreateTag = vi.fn();
|
const mockCreateTag = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/api/tags', () => ({
|
vi.mock('@/api/tags', () => ({
|
||||||
fetchTags: (...args: unknown[]) => mockFetchTags(...args),
|
fetchTags: (...args: any[]) => mockFetchTags(...args),
|
||||||
assignTag: (...args: unknown[]) => mockAssignTag(...args),
|
assignTag: (...args: any[]) => mockAssignTag(...args),
|
||||||
unassignTag: (...args: unknown[]) => mockUnassignTag(...args),
|
unassignTag: (...args: any[]) => mockUnassignTag(...args),
|
||||||
createTag: (...args: unknown[]) => mockCreateTag(...args),
|
createTag: (...args: any[]) => mockCreateTag(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/Toast', () => ({
|
import { TagPicker } from '@/components/tags/TagPicker';
|
||||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
|
||||||
}));
|
const mockTags = [
|
||||||
|
{ id: 't1', name: 'VIP-Kunde', color: '#10B981', created_by: 'u1' },
|
||||||
|
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1' },
|
||||||
|
{ id: 't3', name: 'Archiv', color: '#6B7280', created_by: 'u1' },
|
||||||
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockFetchTags.mockResolvedValue(mockTags);
|
mockFetchTags.mockResolvedValue(mockTags);
|
||||||
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
|
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
|
||||||
mockUnassignTag.mockResolvedValue(undefined);
|
mockUnassignTag.mockResolvedValue(undefined);
|
||||||
mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' });
|
mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TagPicker', () => {
|
describe('TagPicker', () => {
|
||||||
it('renders the tag picker container', async () => {
|
it('renders the tag picker container', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders assigned tags section', async () => {
|
it('renders assigned tags section', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument();
|
expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no tags assigned message when empty', async () => {
|
it('shows no tags assigned message when empty', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument();
|
expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads and displays available tags', async () => {
|
it('loads and displays available tags', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchTags).toHaveBeenCalled();
|
expect(mockFetchTags).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -64,18 +61,18 @@ describe('TagPicker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('assigns a tag when clicking available tag', async () => {
|
it('assigns a tag when clicking available tag', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByText('VIP-Kunde'));
|
fireEvent.click(screen.getByText('VIP-Kunde'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
|
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows create tag form when clicking create button', async () => {
|
it('shows create tag form when clicking create button', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Neues Tag')).toBeInTheDocument();
|
expect(screen.getByText('Neues Tag')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -86,7 +83,7 @@ describe('TagPicker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders search input for tags', async () => {
|
it('renders search input for tags', async () => {
|
||||||
render(<TagPicker entityType="company" entityId="c1" />);
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument();
|
expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface CalendarEntry {
|
|||||||
|
|
||||||
export interface CalendarEntryLink {
|
export interface CalendarEntryLink {
|
||||||
id: string;
|
id: string;
|
||||||
entity_type: 'company' | 'contact';
|
entity_type: 'contact' | 'company';
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* hooks.ts — Re-Export Hub
|
* hooks.ts — Re-Export Hub (Company hooks removed in Phase 1C)
|
||||||
*
|
*
|
||||||
* This file re-exports all hooks from their dedicated modules so that
|
* This file re-exports all hooks from their dedicated modules so that
|
||||||
* existing imports `from '@/api/hooks'` continue to work without change.
|
* existing imports `from '@/api/hooks'` continue to work without change.
|
||||||
*
|
|
||||||
* Company hooks remain inline here — they will be removed in Phase 1.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete, apiClient } from './client';
|
|
||||||
import { PaginatedResponse, Company, CompanyDetail } from './types';
|
|
||||||
|
|
||||||
// ── Re-exports from dedicated modules ──
|
// ── Re-exports from dedicated modules ──
|
||||||
export * from './types';
|
export * from './types';
|
||||||
@@ -48,89 +44,3 @@ export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
|||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Company hooks (will be removed in Phase 1) ──
|
|
||||||
|
|
||||||
export function useCompanies(page = 1, pageSize = 25, search?: string, industry?: string, sortBy?: string, sortOrder?: string) {
|
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
|
||||||
if (search) params.set('search', search);
|
|
||||||
if (industry) params.set('industry', industry);
|
|
||||||
if (sortBy) params.set('sort_by', sortBy);
|
|
||||||
if (sortOrder) params.set('sort_order', sortOrder);
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['companies', page, pageSize, search, industry, sortBy, sortOrder],
|
|
||||||
queryFn: () =>
|
|
||||||
apiGet<PaginatedResponse<Company>>(`/companies?${params.toString()}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCompany(id?: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['companies', id],
|
|
||||||
queryFn: () => apiGet<CompanyDetail>(`/companies/${id}`),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateCompany() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: Partial<Company>) => apiPost('/companies', data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateCompany() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: Partial<Company> }) =>
|
|
||||||
apiPatch(`/companies/${id}`, data),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['companies', variables.id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteCompany() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => apiDelete(`/companies/${id}`),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCompanyExport(search?: string, industry?: string) {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const params = new URLSearchParams({ format: 'csv' });
|
|
||||||
if (search) params.set('search', search);
|
|
||||||
if (industry) params.set('industry', industry);
|
|
||||||
const response = await apiClient.get(`/companies/export?${params.toString()}`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCompanyImport() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (file: File) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const response = await apiClient.post('/companies/import', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -272,7 +272,6 @@ export interface FlagUpdatePayload {
|
|||||||
|
|
||||||
export interface LinkMailPayload {
|
export interface LinkMailPayload {
|
||||||
contact_id?: string | null;
|
contact_id?: string | null;
|
||||||
company_id?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateEventFromMailPayload {
|
export interface CreateEventFromMailPayload {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
type: 'company' | 'contact' | 'mail' | 'file' | 'event';
|
type: 'contact' | 'mail' | 'file' | 'event';
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { apiDelete, apiGet, apiPatch, apiPost } from './client';
|
|||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type EntityType = 'company' | 'contact' | 'file' | 'calendar_entry';
|
export type EntityType = 'contact' | 'file' | 'calendar_entry';
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -9,23 +9,6 @@ export interface PaginatedResponse<T> {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Company {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
account_number?: string | null;
|
|
||||||
industry?: string | null;
|
|
||||||
phone?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
website?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompanyDetail extends Company {
|
|
||||||
contacts?: Contact[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -39,5 +22,5 @@ export interface Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactDetail extends Contact {
|
export interface ContactDetail extends Contact {
|
||||||
companies?: Company[];
|
companies?: Array<{ id: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
@@ -18,6 +21,46 @@ export interface ContactEditModalProps {
|
|||||||
onSaved?: (id: string) => void;
|
onSaved?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zod Schema ──
|
||||||
|
|
||||||
|
const phoneRegex = /^[+]?[\d\s\-().]{6,20}$/;
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
type: z.enum(['company', 'person']),
|
||||||
|
name: z.string().optional().default(''),
|
||||||
|
firstname: z.string().optional().default(''),
|
||||||
|
surname: z.string().optional().default(''),
|
||||||
|
code: z.string().optional().default(''),
|
||||||
|
email_1: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
|
||||||
|
email_2: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
|
||||||
|
phone_1: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
|
||||||
|
phone_2: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
|
||||||
|
website: z.string().optional().default(''),
|
||||||
|
mailing_street: z.string().optional().default(''),
|
||||||
|
mailing_number: z.string().optional().default(''),
|
||||||
|
mailing_postalcode: z.string().optional().default(''),
|
||||||
|
mailing_city: z.string().optional().default(''),
|
||||||
|
mailing_country: z.string().optional().default(''),
|
||||||
|
vat_code: z.string().optional().default(''),
|
||||||
|
fiscal_code: z.string().optional().default(''),
|
||||||
|
commerce_code: z.string().optional().default(''),
|
||||||
|
bic: z.string().optional().default(''),
|
||||||
|
bank_account: z.string().optional().default(''),
|
||||||
|
tags: z.string().optional().default(''),
|
||||||
|
projectnote: z.string().optional().default(''),
|
||||||
|
contact_warning: z.string().optional().default(''),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.type === 'company' && !data.name?.trim()) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['name'] });
|
||||||
|
}
|
||||||
|
if (data.type === 'person' && !data.firstname?.trim() && !data.surname?.trim()) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['firstname'] });
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['surname'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type ContactFormData = z.infer<typeof contactSchema>;
|
||||||
|
|
||||||
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
|
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -25,83 +68,99 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
const updateMutation = useUpdateUnifiedContact();
|
const updateMutation = useUpdateUnifiedContact();
|
||||||
const isEdit = !!contact;
|
const isEdit = !!contact;
|
||||||
|
|
||||||
const [type, setType] = useState<'company' | 'person'>('company');
|
const {
|
||||||
const [name, setName] = useState('');
|
register,
|
||||||
const [firstname, setFirstname] = useState('');
|
handleSubmit,
|
||||||
const [surname, setSurname] = useState('');
|
reset,
|
||||||
const [code, setCode] = useState('');
|
watch,
|
||||||
const [email1, setEmail1] = useState('');
|
formState: { errors, isSubmitting },
|
||||||
const [email2, setEmail2] = useState('');
|
} = useForm<ContactFormData>({
|
||||||
const [phone1, setPhone1] = useState('');
|
resolver: zodResolver(contactSchema),
|
||||||
const [phone2, setPhone2] = useState('');
|
defaultValues: {
|
||||||
const [website, setWebsite] = useState('');
|
type: 'company',
|
||||||
const [mailingStreet, setMailingStreet] = useState('');
|
name: '',
|
||||||
const [mailingNumber, setMailingNumber] = useState('');
|
firstname: '',
|
||||||
const [mailingPostalcode, setMailingPostalcode] = useState('');
|
surname: '',
|
||||||
const [mailingCity, setMailingCity] = useState('');
|
code: '',
|
||||||
const [mailingCountry, setMailingCountry] = useState('');
|
email_1: '',
|
||||||
const [vatCode, setVatCode] = useState('');
|
email_2: '',
|
||||||
const [fiscalCode, setFiscalCode] = useState('');
|
phone_1: '',
|
||||||
const [commerceCode, setCommerceCode] = useState('');
|
phone_2: '',
|
||||||
const [bic, setBic] = useState('');
|
website: '',
|
||||||
const [bankAccount, setBankAccount] = useState('');
|
mailing_street: '',
|
||||||
const [tags, setTags] = useState('');
|
mailing_number: '',
|
||||||
const [projectnote, setProjectnote] = useState('');
|
mailing_postalcode: '',
|
||||||
const [contactWarning, setContactWarning] = useState('');
|
mailing_city: '',
|
||||||
|
mailing_country: '',
|
||||||
|
vat_code: '',
|
||||||
|
fiscal_code: '',
|
||||||
|
commerce_code: '',
|
||||||
|
bic: '',
|
||||||
|
bank_account: '',
|
||||||
|
tags: '',
|
||||||
|
projectnote: '',
|
||||||
|
contact_warning: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentType = watch('type');
|
||||||
|
|
||||||
|
// Reset form when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setType((contact?.type as 'company' | 'person') || 'company');
|
reset({
|
||||||
setName(contact?.name || '');
|
type: (contact?.type as 'company' | 'person') || 'company',
|
||||||
setFirstname(contact?.firstname || '');
|
name: contact?.name || '',
|
||||||
setSurname(contact?.surname || '');
|
firstname: contact?.firstname || '',
|
||||||
setCode(contact?.code || '');
|
surname: contact?.surname || '',
|
||||||
setEmail1(contact?.email_1 || '');
|
code: contact?.code || '',
|
||||||
setEmail2(contact?.email_2 || '');
|
email_1: contact?.email_1 || '',
|
||||||
setPhone1(contact?.phone_1 || '');
|
email_2: contact?.email_2 || '',
|
||||||
setPhone2(contact?.phone_2 || '');
|
phone_1: contact?.phone_1 || '',
|
||||||
setWebsite(contact?.website || '');
|
phone_2: contact?.phone_2 || '',
|
||||||
setMailingStreet(contact?.mailing_street || '');
|
website: contact?.website || '',
|
||||||
setMailingNumber(contact?.mailing_number || '');
|
mailing_street: contact?.mailing_street || '',
|
||||||
setMailingPostalcode(contact?.mailing_postalcode || '');
|
mailing_number: contact?.mailing_number || '',
|
||||||
setMailingCity(contact?.mailing_city || '');
|
mailing_postalcode: contact?.mailing_postalcode || '',
|
||||||
setMailingCountry(contact?.mailing_country || '');
|
mailing_city: contact?.mailing_city || '',
|
||||||
setVatCode(contact?.vat_code || '');
|
mailing_country: contact?.mailing_country || '',
|
||||||
setFiscalCode(contact?.fiscal_code || '');
|
vat_code: contact?.vat_code || '',
|
||||||
setCommerceCode(contact?.commerce_code || '');
|
fiscal_code: contact?.fiscal_code || '',
|
||||||
setBic(contact?.bic || '');
|
commerce_code: contact?.commerce_code || '',
|
||||||
setBankAccount(contact?.bank_account || '');
|
bic: contact?.bic || '',
|
||||||
setTags(contact?.tags || '');
|
bank_account: contact?.bank_account || '',
|
||||||
setProjectnote(contact?.projectnote || '');
|
tags: contact?.tags || '',
|
||||||
setContactWarning(contact?.contact_warning || '');
|
projectnote: contact?.projectnote || '',
|
||||||
|
contact_warning: contact?.contact_warning || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [open, contact]);
|
}, [open, contact, reset]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const onSubmit = async (formData: ContactFormData) => {
|
||||||
const data: Partial<UnifiedContact> = {
|
const data: Partial<UnifiedContact> = {
|
||||||
type,
|
type: formData.type,
|
||||||
name: type === 'company' ? name : null,
|
name: formData.type === 'company' ? formData.name || null : null,
|
||||||
firstname: type === 'person' ? firstname : null,
|
firstname: formData.type === 'person' ? formData.firstname || null : null,
|
||||||
surname: type === 'person' ? surname : null,
|
surname: formData.type === 'person' ? formData.surname || null : null,
|
||||||
code: code || null,
|
code: formData.code || null,
|
||||||
email_1: email1 || null,
|
email_1: formData.email_1 || null,
|
||||||
email_2: email2 || null,
|
email_2: formData.email_2 || null,
|
||||||
phone_1: phone1 || null,
|
phone_1: formData.phone_1 || null,
|
||||||
phone_2: phone2 || null,
|
phone_2: formData.phone_2 || null,
|
||||||
website: website || null,
|
website: formData.website || null,
|
||||||
mailing_street: mailingStreet || null,
|
mailing_street: formData.mailing_street || null,
|
||||||
mailing_number: mailingNumber || null,
|
mailing_number: formData.mailing_number || null,
|
||||||
mailing_postalcode: mailingPostalcode || null,
|
mailing_postalcode: formData.mailing_postalcode || null,
|
||||||
mailing_city: mailingCity || null,
|
mailing_city: formData.mailing_city || null,
|
||||||
mailing_country: mailingCountry || null,
|
mailing_country: formData.mailing_country || null,
|
||||||
vat_code: vatCode || null,
|
vat_code: formData.vat_code || null,
|
||||||
fiscal_code: fiscalCode || null,
|
fiscal_code: formData.fiscal_code || null,
|
||||||
commerce_code: commerceCode || null,
|
commerce_code: formData.commerce_code || null,
|
||||||
bic: bic || null,
|
bic: formData.bic || null,
|
||||||
bank_account: bankAccount || null,
|
bank_account: formData.bank_account || null,
|
||||||
tags: tags || null,
|
tags: formData.tags || null,
|
||||||
projectnote: projectnote || null,
|
projectnote: formData.projectnote || null,
|
||||||
contact_warning: contactWarning || null,
|
contact_warning: formData.contact_warning || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -122,12 +181,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
|
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
|
||||||
<div className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{/* Type */}
|
{/* Type */}
|
||||||
<Select
|
<Select
|
||||||
label={t('contacts.type')}
|
label={t('contacts.type')}
|
||||||
value={type}
|
{...register('type')}
|
||||||
onChange={(e) => setType(e.target.value as 'company' | 'person')}
|
|
||||||
options={[
|
options={[
|
||||||
{ value: 'company', label: t('contacts.companies') },
|
{ value: 'company', label: t('contacts.companies') },
|
||||||
{ value: 'person', label: t('contacts.persons') },
|
{ value: 'person', label: t('contacts.persons') },
|
||||||
@@ -136,27 +194,44 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name fields */}
|
{/* Name fields */}
|
||||||
{type === 'company' ? (
|
{currentType === 'company' ? (
|
||||||
<Input label={t('contacts.name')} value={name} onChange={(e) => setName(e.target.value)} required data-testid="contact-name-input" placeholder="TechCorp GmbH" />
|
<Input
|
||||||
|
label={t('contacts.name')}
|
||||||
|
{...register('name')}
|
||||||
|
error={errors.name?.message}
|
||||||
|
required
|
||||||
|
data-testid="contact-name-input"
|
||||||
|
placeholder="TechCorp GmbH"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label={t('contacts.firstName')} value={firstname} onChange={(e) => setFirstname(e.target.value)} data-testid="contact-first-name-input" />
|
<Input
|
||||||
<Input label={t('contacts.lastName')} value={surname} onChange={(e) => setSurname(e.target.value)} data-testid="contact-last-name-input" />
|
label={t('contacts.firstName')}
|
||||||
|
{...register('firstname')}
|
||||||
|
error={errors.firstname?.message}
|
||||||
|
data-testid="contact-first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('contacts.lastName')}
|
||||||
|
{...register('surname')}
|
||||||
|
error={errors.surname?.message}
|
||||||
|
data-testid="contact-last-name-input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Code */}
|
{/* Code */}
|
||||||
<Input label={t('contacts.code')} value={code} onChange={(e) => setCode(e.target.value)} placeholder="K-00123" />
|
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
|
||||||
|
|
||||||
{/* Communication */}
|
{/* Communication */}
|
||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label={t('contacts.email') + ' 1'} type="email" value={email1} onChange={(e) => setEmail1(e.target.value)} />
|
<Input label={t('contacts.email') + ' 1'} type="email" {...register('email_1')} error={errors.email_1?.message} />
|
||||||
<Input label={t('contacts.email') + ' 2'} type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} />
|
<Input label={t('contacts.email') + ' 2'} type="email" {...register('email_2')} error={errors.email_2?.message} />
|
||||||
<Input label={t('contacts.phone') + ' 1'} value={phone1} onChange={(e) => setPhone1(e.target.value)} />
|
<Input label={t('contacts.phone') + ' 1'} {...register('phone_1')} error={errors.phone_1?.message} />
|
||||||
<Input label={t('contacts.phone') + ' 2'} value={phone2} onChange={(e) => setPhone2(e.target.value)} />
|
<Input label={t('contacts.phone') + ' 2'} {...register('phone_2')} error={errors.phone_2?.message} />
|
||||||
<Input label={t('contacts.website')} value={website} onChange={(e) => setWebsite(e.target.value)} />
|
<Input label={t('contacts.website')} {...register('website')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,11 +239,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label={t('address.street')} value={mailingStreet} onChange={(e) => setMailingStreet(e.target.value)} />
|
<Input label={t('address.street')} {...register('mailing_street')} />
|
||||||
<Input label={t('address.streetNumber')} value={mailingNumber} onChange={(e) => setMailingNumber(e.target.value)} />
|
<Input label={t('address.streetNumber')} {...register('mailing_number')} />
|
||||||
<Input label={t('address.zip')} value={mailingPostalcode} onChange={(e) => setMailingPostalcode(e.target.value)} />
|
<Input label={t('address.zip')} {...register('mailing_postalcode')} />
|
||||||
<Input label={t('address.city')} value={mailingCity} onChange={(e) => setMailingCity(e.target.value)} />
|
<Input label={t('address.city')} {...register('mailing_city')} />
|
||||||
<Input label={t('address.country')} value={mailingCountry} onChange={(e) => setMailingCountry(e.target.value)} />
|
<Input label={t('address.country')} {...register('mailing_country')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,11 +251,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label={t('contacts.vatCode')} value={vatCode} onChange={(e) => setVatCode(e.target.value)} />
|
<Input label={t('contacts.vatCode')} {...register('vat_code')} />
|
||||||
<Input label={t('contacts.fiscalCode')} value={fiscalCode} onChange={(e) => setFiscalCode(e.target.value)} />
|
<Input label={t('contacts.fiscalCode')} {...register('fiscal_code')} />
|
||||||
<Input label={t('contacts.commerceCode')} value={commerceCode} onChange={(e) => setCommerceCode(e.target.value)} />
|
<Input label={t('contacts.commerceCode')} {...register('commerce_code')} />
|
||||||
<Input label={t('contacts.bic')} value={bic} onChange={(e) => setBic(e.target.value)} />
|
<Input label={t('contacts.bic')} {...register('bic')} />
|
||||||
<Input label={t('contacts.bankAccount')} value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
<Input label={t('contacts.bankAccount')} {...register('bank_account')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,20 +263,18 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input label={t('contacts.tags')} value={tags} onChange={(e) => setTags(e.target.value)} placeholder="tag1, tag2" />
|
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={projectnote}
|
{...register('projectnote')}
|
||||||
onChange={(e) => setProjectnote(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={contactWarning}
|
{...register('contact_warning')}
|
||||||
onChange={(e) => setContactWarning(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,11 +284,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||||
<Button onClick={handleSave} isLoading={createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
||||||
{isEdit ? t('common.save') : t('common.create')}
|
{isEdit ? t('common.save') : t('common.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback } from 'react';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { useCompanyImport } from '@/api/hooks';
|
import { apiClient } from '@/api/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface CsvImportDialogProps {
|
export interface CsvImportDialogProps {
|
||||||
@@ -35,7 +35,7 @@ function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
|
|||||||
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
|
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const importMutation = useCompanyImport();
|
const [importing, setImporting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
|
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
|
||||||
@@ -61,8 +61,13 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
|||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
await importMutation.mutateAsync(selectedFile);
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
await apiClient.post('/contacts/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
toast.success('Import erfolgreich abgeschlossen.');
|
toast.success('Import erfolgreich abgeschlossen.');
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
@@ -71,6 +76,8 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.message || 'Import fehlgeschlagen.');
|
toast.error(err.message || 'Import fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,8 +141,8 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
|||||||
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
|
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={!selectedFile || importMutation.isPending}
|
disabled={!selectedFile || importing}
|
||||||
isLoading={importMutation.isPending}
|
isLoading={importing}
|
||||||
data-testid="csv-import-button"
|
data-testid="csv-import-button"
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Contact Detail Page — loads a single contact by ID from route params.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ContactDetail } from '@/components/contacts/ContactDetail';
|
||||||
|
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
|
||||||
|
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ContactDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: contact, isLoading } = useUnifiedContact(id);
|
||||||
|
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||||
|
const [editingContact, setEditingContact] = React.useState<UnifiedContact | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (contact) {
|
||||||
|
setEditingContact(contact);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleted = () => {
|
||||||
|
navigate('/contacts');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setEditingContact(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full" data-testid="contact-detail-page">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/contacts')}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span>{t('contacts.title')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<ContactDetail
|
||||||
|
contact={contact ?? null}
|
||||||
|
loading={isLoading}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContactEditModal
|
||||||
|
open={editModalOpen}
|
||||||
|
onClose={() => setEditModalOpen(false)}
|
||||||
|
contact={editingContact}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -264,6 +264,30 @@ export function ContactsListPage() {
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Type filter toggle */}
|
||||||
|
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5 mr-1">
|
||||||
|
{[
|
||||||
|
{ value: 'all' as ContactFilter, label: t('common.all') },
|
||||||
|
{ value: 'company' as ContactFilter, label: t('contacts.companies') },
|
||||||
|
{ value: 'person' as ContactFilter, label: t('contacts.persons') },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelectFilter(opt.value)}
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
|
||||||
|
selectedFilter === opt.value
|
||||||
|
? 'bg-white text-primary-600 shadow-sm'
|
||||||
|
: 'text-secondary-500 hover:text-secondary-700',
|
||||||
|
)}
|
||||||
|
aria-label={opt.label}
|
||||||
|
aria-pressed={selectedFilter === opt.value}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* View mode toggle */}
|
{/* View mode toggle */}
|
||||||
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
|
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
|
||||||
{viewModeOptions.map((opt) => (
|
{viewModeOptions.map((opt) => (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Loader2 } from 'lucide-react';
|
|||||||
// 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 })));
|
||||||
const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage })));
|
const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage })));
|
||||||
|
const ContactDetailPage = React.lazy(() => import('@/pages/ContactDetailPage').then(m => ({ default: m.ContactDetailPage })));
|
||||||
const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage })));
|
const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage })));
|
||||||
const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage })));
|
const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage })));
|
||||||
const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage })));
|
const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage })));
|
||||||
@@ -71,6 +72,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/', element: withSuspense(<DashboardPage />) },
|
{ path: '/', element: withSuspense(<DashboardPage />) },
|
||||||
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
||||||
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
|
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
|
||||||
|
{ path: '/contacts/:id', element: withSuspense(<ContactDetailPage />) },
|
||||||
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
|
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
|
||||||
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
||||||
{ path: '/calendar', element: withSuspense(<CalendarPage />) },
|
{ path: '/calendar', element: withSuspense(<CalendarPage />) },
|
||||||
|
|||||||
+5
-5
@@ -29,8 +29,8 @@ from app.core.db import Base, close_engine, reset_engine_for_testing
|
|||||||
from app.core.service_container import get_container # noqa: F401
|
from app.core.service_container import get_container # noqa: F401
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact
|
||||||
from app.models.contact import CompanyContact, Contact # noqa: F401
|
from app.models.contact import Contact, ContactPerson # noqa: F401
|
||||||
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
@@ -131,7 +131,7 @@ def clean_tables(db_setup):
|
|||||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
|
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -268,7 +268,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Create a company in tenant A
|
# Create a company in tenant A
|
||||||
company_a = Company(
|
company_a = Contact(
|
||||||
tenant_id=tenant_a.id,
|
tenant_id=tenant_a.id,
|
||||||
name="Company Alpha",
|
name="Company Alpha",
|
||||||
industry="IT",
|
industry="IT",
|
||||||
@@ -276,7 +276,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
|||||||
updated_by=admin_a.id,
|
updated_by=admin_a.id,
|
||||||
)
|
)
|
||||||
# Create a company in tenant B
|
# Create a company in tenant B
|
||||||
company_b = Company(
|
company_b = Contact(
|
||||||
tenant_id=tenant_b.id,
|
tenant_id=tenant_b.id,
|
||||||
name="Company Beta",
|
name="Company Beta",
|
||||||
industry="Finance",
|
industry="Finance",
|
||||||
|
|||||||
@@ -1012,7 +1012,7 @@ async def test_gather_context_mail(db_session: AsyncSession):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gather_context_company(db_session: AsyncSession):
|
async def test_gather_context_company(db_session: AsyncSession):
|
||||||
"""gather_context for company collects data."""
|
"""gather_context for company collects data."""
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact as Company
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
@@ -1041,8 +1041,8 @@ async def test_gather_context_company(db_session: AsyncSession):
|
|||||||
db_session.add(company)
|
db_session.add(company)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
context = await gather_context(db_session, "company", company.id, tenant.id)
|
context = await gather_context(db_session, "contact", company.id, tenant.id)
|
||||||
assert context["entity_type"] == "company"
|
assert context["entity_type"] == "contact"
|
||||||
assert context["entity_id"] == str(company.id)
|
assert context["entity_id"] == str(company.id)
|
||||||
assert "company" in context
|
assert "company" in context
|
||||||
assert "contacts" in context
|
assert "contacts" in context
|
||||||
@@ -1463,7 +1463,7 @@ async def test_suggestions_filter_by_entity_type(
|
|||||||
s_company = ProactiveSuggestion(
|
s_company = ProactiveSuggestion(
|
||||||
tenant_id=seed["tenant_a"].id,
|
tenant_id=seed["tenant_a"].id,
|
||||||
user_id=seed["admin_a"].id,
|
user_id=seed["admin_a"].id,
|
||||||
entity_type="company",
|
entity_type="contact",
|
||||||
entity_id=uuid.uuid4(),
|
entity_id=uuid.uuid4(),
|
||||||
suggestion_type="info",
|
suggestion_type="info",
|
||||||
title="Company Suggestion",
|
title="Company Suggestion",
|
||||||
|
|||||||
@@ -428,12 +428,12 @@ async def test_ac14_link_entry(calendar_authed_client):
|
|||||||
company_id = str(seed["company_a"].id)
|
company_id = str(seed["company_a"].id)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/calendar/entries/{entry_id}/link",
|
f"/api/v1/calendar/entries/{entry_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["entity_type"] == "company"
|
assert data["entity_type"] == "contact"
|
||||||
assert data["entity_id"] == company_id
|
assert data["entity_id"] == company_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -66,13 +66,13 @@ async def test_link_file_to_company(authed_client: AsyncClient):
|
|||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["file_id"] == file_id
|
assert data["file_id"] == file_id
|
||||||
assert data["entity_type"] == "company"
|
assert data["entity_type"] == "contact"
|
||||||
assert data["entity_id"] == company_id
|
assert data["entity_id"] == company_id
|
||||||
assert data["already_linked"] is False
|
assert data["already_linked"] is False
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
|
|||||||
# Link first
|
# Link first
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -115,7 +115,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
|
|||||||
resp = await client.request(
|
resp = await client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
@@ -137,7 +137,7 @@ async def test_list_file_links(authed_client: AsyncClient):
|
|||||||
# Link to company
|
# Link to company
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
# Link to contact
|
# Link to contact
|
||||||
@@ -164,7 +164,7 @@ async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
|
|||||||
entity_id = str(uuid.uuid4())
|
entity_id = str(uuid.uuid4())
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": entity_id},
|
json={"entity_type": "contact", "entity_id": entity_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -228,7 +228,7 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
|
|||||||
file_id = str(uuid.uuid4())
|
file_id = str(uuid.uuid4())
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": str(company_id)},
|
json={"entity_type": "contact", "entity_id": str(company_id)},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -309,7 +309,7 @@ async def test_link_invalid_file_id(authed_client: AsyncClient):
|
|||||||
client, seed = authed_client
|
client, seed = authed_client
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/dms/files/bad-uuid/link",
|
"/api/v1/dms/files/bad-uuid/link",
|
||||||
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
@@ -321,7 +321,7 @@ async def test_link_invalid_entity_id(authed_client: AsyncClient):
|
|||||||
client, seed = authed_client
|
client, seed = authed_client
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
||||||
json={"entity_type": "company", "entity_id": "bad-uuid"},
|
json={"entity_type": "contact", "entity_id": "bad-uuid"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
@@ -347,14 +347,14 @@ async def test_link_already_linked(authed_client: AsyncClient):
|
|||||||
company_id = str(seed["company_a"].id)
|
company_id = str(seed["company_a"].id)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["already_linked"] is False
|
assert resp.json()["already_linked"] is False
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -370,7 +370,7 @@ async def test_unlink_not_found(authed_client: AsyncClient):
|
|||||||
resp = await client.request(
|
resp = await client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/api/v1/dms/files/{file_id}/link",
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
json={"entity_type": "company", "entity_id": company_id},
|
json={"entity_type": "contact", "entity_id": company_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
@@ -383,7 +383,7 @@ async def test_unlink_invalid_file_id(authed_client: AsyncClient):
|
|||||||
resp = await client.request(
|
resp = await client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"/api/v1/dms/files/bad-uuid/link",
|
"/api/v1/dms/files/bad-uuid/link",
|
||||||
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|||||||
+7
-7
@@ -164,13 +164,13 @@ async def test_assign_tag(authed_client: AsyncClient):
|
|||||||
|
|
||||||
resp = await authed_client.post(
|
resp = await authed_client.post(
|
||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["tag_id"] == tag_id
|
assert data["tag_id"] == tag_id
|
||||||
assert data["entity_type"] == "company"
|
assert data["entity_type"] == "contact"
|
||||||
assert data["entity_id"] == entity_id
|
assert data["entity_id"] == entity_id
|
||||||
assert data["already_assigned"] is False
|
assert data["already_assigned"] is False
|
||||||
|
|
||||||
@@ -377,7 +377,7 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
|
|||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={
|
json={
|
||||||
"tag_id": str(uuid.uuid4()),
|
"tag_id": str(uuid.uuid4()),
|
||||||
"entity_type": "company",
|
"entity_type": "contact",
|
||||||
"entity_id": str(uuid.uuid4()),
|
"entity_id": str(uuid.uuid4()),
|
||||||
},
|
},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
@@ -397,14 +397,14 @@ async def test_assign_tag_already_assigned(authed_client: AsyncClient):
|
|||||||
entity_id = str(uuid.uuid4())
|
entity_id = str(uuid.uuid4())
|
||||||
resp = await authed_client.post(
|
resp = await authed_client.post(
|
||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["already_assigned"] is False
|
assert resp.json()["already_assigned"] is False
|
||||||
resp = await authed_client.post(
|
resp = await authed_client.post(
|
||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -416,7 +416,7 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
|
|||||||
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
|
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
|
||||||
resp = await authed_client.post(
|
resp = await authed_client.post(
|
||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={"tag_id": "bad", "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
json={"tag_id": "bad", "entity_type": "contact", "entity_id": str(uuid.uuid4())},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
@@ -434,7 +434,7 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient):
|
|||||||
resp = await authed_client.request(
|
resp = await authed_client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"/api/v1/tags/assign",
|
"/api/v1/tags/assign",
|
||||||
json={"tag_id": tag_id, "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": str(uuid.uuid4())},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ async def test_ac1_search_returns_results(
|
|||||||
client, _ = search_authed_client
|
client, _ = search_authed_client
|
||||||
mock_hybrid_search.return_value = [
|
mock_hybrid_search.return_value = [
|
||||||
{
|
{
|
||||||
"entity_type": "company",
|
"entity_type": "contact",
|
||||||
"entity_id": str(uuid.uuid4()),
|
"entity_id": str(uuid.uuid4()),
|
||||||
"title": "Company Alpha",
|
"title": "Company Alpha",
|
||||||
"snippet": "IT company",
|
"snippet": "IT company",
|
||||||
@@ -186,7 +186,7 @@ async def test_ac1_search_returns_results(
|
|||||||
assert "results" in data
|
assert "results" in data
|
||||||
assert isinstance(data["results"], list)
|
assert isinstance(data["results"], list)
|
||||||
assert len(data["results"]) > 0
|
assert len(data["results"]) > 0
|
||||||
assert data["results"][0]["entity_type"] == "company"
|
assert data["results"][0]["entity_type"] == "contact"
|
||||||
assert data["results"][0]["title"] == "Company Alpha"
|
assert data["results"][0]["title"] == "Company Alpha"
|
||||||
assert "score" in data["results"][0]
|
assert "score" in data["results"][0]
|
||||||
assert "query" in data
|
assert "query" in data
|
||||||
@@ -209,13 +209,13 @@ async def test_ac2_search_entity_types_filter(
|
|||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/search",
|
"/api/v1/search",
|
||||||
json={"query": "test", "entity_types": ["company"]},
|
json={"query": "test", "entity_types": ["contact"]},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Verify hybrid_search was called with entity_types=["company"]
|
# Verify hybrid_search was called with entity_types=["contact"]
|
||||||
call_kwargs = mock_hybrid_search.call_args
|
call_kwargs = mock_hybrid_search.call_args
|
||||||
assert call_kwargs.kwargs.get("entity_types") == ["company"]
|
assert call_kwargs.kwargs.get("entity_types") == ["contact"]
|
||||||
|
|
||||||
|
|
||||||
# ─── AC3: No auth → 401 ───
|
# ─── AC3: No auth → 401 ───
|
||||||
@@ -322,7 +322,7 @@ async def test_ac8_similar_returns_results(
|
|||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/search/similar",
|
"/api/v1/search/similar",
|
||||||
json={
|
json={
|
||||||
"entity_type": "company",
|
"entity_type": "contact",
|
||||||
"entity_id": str(seed["company_a"].id),
|
"entity_id": str(seed["company_a"].id),
|
||||||
"limit": 5,
|
"limit": 5,
|
||||||
},
|
},
|
||||||
@@ -347,7 +347,7 @@ async def test_ac9_similar_tenant_isolation(
|
|||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/search/similar",
|
"/api/v1/search/similar",
|
||||||
json={
|
json={
|
||||||
"entity_type": "company",
|
"entity_type": "contact",
|
||||||
"entity_id": str(seed["company_b"].id),
|
"entity_id": str(seed["company_b"].id),
|
||||||
"limit": 5,
|
"limit": 5,
|
||||||
},
|
},
|
||||||
@@ -398,7 +398,7 @@ async def test_ac11_reindex_admin_200(
|
|||||||
client, _ = search_authed_client
|
client, _ = search_authed_client
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/search/reindex",
|
"/api/v1/search/reindex",
|
||||||
json={"entity_types": ["company"]},
|
json={"entity_types": ["contact"]},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -420,7 +420,7 @@ async def test_ac12_reindex_viewer_403(
|
|||||||
|
|
||||||
resp = await search_client.post(
|
resp = await search_client.post(
|
||||||
"/api/v1/search/reindex",
|
"/api/v1/search/reindex",
|
||||||
json={"entity_types": ["company"]},
|
json={"entity_types": ["contact"]},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
@@ -440,7 +440,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
|
|||||||
{"id": "3", "title": "Result C", "score": 0.7},
|
{"id": "3", "title": "Result C", "score": 0.7},
|
||||||
]
|
]
|
||||||
|
|
||||||
fused = rrf_fusion(fts_results, vec_results, "company")
|
fused = rrf_fusion(fts_results, vec_results, "contact")
|
||||||
|
|
||||||
# All 3 unique IDs should be present
|
# All 3 unique IDs should be present
|
||||||
ids = [str(r.get("id", "")) for r in fused]
|
ids = [str(r.get("id", "")) for r in fused]
|
||||||
@@ -457,7 +457,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
|
|||||||
|
|
||||||
def test_rrf_fusion_empty_inputs():
|
def test_rrf_fusion_empty_inputs():
|
||||||
"""RRF fusion with empty inputs returns empty list."""
|
"""RRF fusion with empty inputs returns empty list."""
|
||||||
fused = rrf_fusion([], [], "company")
|
fused = rrf_fusion([], [], "contact")
|
||||||
assert fused == []
|
assert fused == []
|
||||||
|
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ def test_rrf_fusion_fts_only():
|
|||||||
{"id": "1", "title": "A"},
|
{"id": "1", "title": "A"},
|
||||||
{"id": "2", "title": "B"},
|
{"id": "2", "title": "B"},
|
||||||
]
|
]
|
||||||
fused = rrf_fusion(fts_results, [], "company")
|
fused = rrf_fusion(fts_results, [], "contact")
|
||||||
assert len(fused) == 2
|
assert len(fused) == 2
|
||||||
# First result (rank 0) should have higher score
|
# First result (rank 0) should have higher score
|
||||||
assert fused[0]["_score"] > fused[1]["_score"]
|
assert fused[0]["_score"] > fused[1]["_score"]
|
||||||
@@ -547,7 +547,7 @@ def test_provider_get_all():
|
|||||||
p1 = MagicMock()
|
p1 = MagicMock()
|
||||||
p1.entity_type = "contact"
|
p1.entity_type = "contact"
|
||||||
p2 = MagicMock()
|
p2 = MagicMock()
|
||||||
p2.entity_type = "company"
|
p2.entity_type = "contact"
|
||||||
|
|
||||||
registry.register(p1)
|
registry.register(p1)
|
||||||
registry.register(p2)
|
registry.register(p2)
|
||||||
@@ -556,7 +556,6 @@ def test_provider_get_all():
|
|||||||
assert len(all_providers) == 2
|
assert len(all_providers) == 2
|
||||||
entity_types = [p.entity_type for p in all_providers]
|
entity_types = [p.entity_type for p in all_providers]
|
||||||
assert "contact" in entity_types
|
assert "contact" in entity_types
|
||||||
assert "company" in entity_types
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_clear():
|
def test_provider_clear():
|
||||||
@@ -577,13 +576,12 @@ def test_provider_get_entity_types():
|
|||||||
p1 = MagicMock()
|
p1 = MagicMock()
|
||||||
p1.entity_type = "contact"
|
p1.entity_type = "contact"
|
||||||
p2 = MagicMock()
|
p2 = MagicMock()
|
||||||
p2.entity_type = "company"
|
p2.entity_type = "contact"
|
||||||
registry.register(p1)
|
registry.register(p1)
|
||||||
registry.register(p2)
|
registry.register(p2)
|
||||||
|
|
||||||
types = registry.get_entity_types()
|
types = registry.get_entity_types()
|
||||||
assert "contact" in types
|
assert "contact" in types
|
||||||
assert "company" in types
|
|
||||||
assert len(types) == 2
|
assert len(types) == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -745,12 +743,12 @@ async def test_llm_aggregate_results_success():
|
|||||||
mock_agg_resp.choices = [MagicMock()]
|
mock_agg_resp.choices = [MagicMock()]
|
||||||
mock_agg_resp.choices[0].message.content = json.dumps({
|
mock_agg_resp.choices[0].message.content = json.dumps({
|
||||||
"summary": "2 Ergebnisse gefunden",
|
"summary": "2 Ergebnisse gefunden",
|
||||||
"facets": {"types": {"company": 1, "contact": 1}},
|
"facets": {"types": {"contact": 2}},
|
||||||
"suggestions": ["Filter by company"],
|
"suggestions": ["Filter by contact"],
|
||||||
})
|
})
|
||||||
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_agg_resp):
|
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_agg_resp):
|
||||||
results = [
|
results = [
|
||||||
{"entity_type": "company", "title": "Company Alpha"},
|
{"entity_type": "contact", "title": "Company Alpha"},
|
||||||
{"entity_type": "contact", "title": "John Doe"},
|
{"entity_type": "contact", "title": "John Doe"},
|
||||||
]
|
]
|
||||||
result = await llm_aggregate_results(results, "test")
|
result = await llm_aggregate_results(results, "test")
|
||||||
@@ -997,7 +995,7 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
|
|||||||
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
||||||
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession):
|
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession):
|
||||||
"""index_company calls index_entity."""
|
"""index_company calls index_entity."""
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact as Company
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
@@ -1038,7 +1036,7 @@ async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncS
|
|||||||
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
||||||
async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession):
|
async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession):
|
||||||
"""reindex iterates over all entities of a type."""
|
"""reindex iterates over all entities of a type."""
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact as Company
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
@@ -1070,7 +1068,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
|
|||||||
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
|
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
|
||||||
mock_factory.return_value = sf
|
mock_factory.return_value = sf
|
||||||
|
|
||||||
await reindex({}, "company")
|
await reindex({}, "contact")
|
||||||
mock_index_entity.assert_called()
|
mock_index_entity.assert_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -1079,7 +1077,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
|
|||||||
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
||||||
async def test_embedding_batch(mock_index_entity, mock_factory, db_session: AsyncSession):
|
async def test_embedding_batch(mock_index_entity, mock_factory, db_session: AsyncSession):
|
||||||
"""embedding_batch finds entities without embedding."""
|
"""embedding_batch finds entities without embedding."""
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact as Company
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
@@ -1169,7 +1167,7 @@ def test_company_provider_get_embedding_text():
|
|||||||
"""CompanyProvider get_embedding_text is a callable method."""
|
"""CompanyProvider get_embedding_text is a callable method."""
|
||||||
provider = CompanySearchProvider()
|
provider = CompanySearchProvider()
|
||||||
assert hasattr(provider, "get_embedding_text")
|
assert hasattr(provider, "get_embedding_text")
|
||||||
assert provider.entity_type == "company"
|
assert provider.entity_type == "contact"
|
||||||
|
|
||||||
|
|
||||||
def test_mail_provider_get_embedding_text():
|
def test_mail_provider_get_embedding_text():
|
||||||
@@ -1210,7 +1208,7 @@ def test_company_provider_to_search_result():
|
|||||||
provider = CompanySearchProvider()
|
provider = CompanySearchProvider()
|
||||||
entity = {"id": "456", "name": "Acme Corp", "description": "IT company"}
|
entity = {"id": "456", "name": "Acme Corp", "description": "IT company"}
|
||||||
result = provider.to_search_result(entity)
|
result = provider.to_search_result(entity)
|
||||||
assert result["entity_type"] == "company"
|
assert result["entity_type"] == "contact"
|
||||||
assert result["entity_id"] == "456"
|
assert result["entity_id"] == "456"
|
||||||
assert result["title"] == "Acme Corp"
|
assert result["title"] == "Acme Corp"
|
||||||
assert "IT company" in result["snippet"]
|
assert "IT company" in result["snippet"]
|
||||||
|
|||||||
Reference in New Issue
Block a user