2025-12-17 16:38:08 +01:00
|
|
|
"""
|
|
|
|
|
Contact API Router - Simplified (Read-Only)
|
|
|
|
|
Only GET endpoints for now
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
|
|
|
|
|
from typing import Any, Optional
|
2026-01-10 21:09:29 +01:00
|
|
|
from pydantic import BaseModel, Field
|
2026-08-17 19:27:43 +02:00
|
|
|
from app.core.database import (
|
|
|
|
|
execute_query,
|
|
|
|
|
execute_insert,
|
|
|
|
|
execute_query_single,
|
|
|
|
|
get_db_connection,
|
|
|
|
|
release_db_connection,
|
|
|
|
|
)
|
|
|
|
|
from psycopg2.extras import RealDictCursor
|
2026-02-11 23:51:21 +01:00
|
|
|
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
|
2026-08-28 20:49:55 +02:00
|
|
|
from app.services.cvr_service import get_cvr_service
|
2026-02-11 23:51:21 +01:00
|
|
|
from app.customers.backend.router import (
|
|
|
|
|
get_customer_subscriptions,
|
|
|
|
|
lock_customer_subscriptions,
|
|
|
|
|
save_subscription_comment,
|
|
|
|
|
get_subscription_comment,
|
|
|
|
|
get_subscription_billing_matrix,
|
|
|
|
|
SubscriptionComment,
|
2026-08-28 20:49:55 +02:00
|
|
|
CustomerCreate,
|
|
|
|
|
create_customer,
|
2026-02-11 23:51:21 +01:00
|
|
|
)
|
2025-12-17 16:38:08 +01:00
|
|
|
import logging
|
2026-08-17 19:27:43 +02:00
|
|
|
import json
|
2026-08-28 20:49:55 +02:00
|
|
|
import re
|
|
|
|
|
import html
|
|
|
|
|
from email.utils import parseaddr
|
2025-12-17 16:38:08 +01:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
class ContactCreate(BaseModel):
|
|
|
|
|
"""Schema for creating a contact"""
|
|
|
|
|
first_name: str
|
|
|
|
|
last_name: str = ""
|
|
|
|
|
email: Optional[str] = None
|
|
|
|
|
phone: Optional[str] = None
|
2026-07-10 08:10:31 +02:00
|
|
|
mobile: Optional[str] = None
|
2026-01-10 21:09:29 +01:00
|
|
|
title: Optional[str] = None
|
2026-07-10 08:10:31 +02:00
|
|
|
department: Optional[str] = None
|
2026-01-10 21:09:29 +01:00
|
|
|
company_id: Optional[int] = None
|
2026-07-10 08:10:31 +02:00
|
|
|
company_ids: Optional[list[int]] = None
|
|
|
|
|
is_primary: bool = False
|
|
|
|
|
role: Optional[str] = None
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
is_active: bool = True
|
2026-01-10 21:09:29 +01:00
|
|
|
|
|
|
|
|
|
2026-02-06 10:47:14 +01:00
|
|
|
class ContactUpdate(BaseModel):
|
|
|
|
|
"""Schema for updating a contact"""
|
|
|
|
|
first_name: Optional[str] = None
|
|
|
|
|
last_name: Optional[str] = None
|
|
|
|
|
email: Optional[str] = None
|
|
|
|
|
phone: Optional[str] = None
|
|
|
|
|
mobile: Optional[str] = None
|
|
|
|
|
title: Optional[str] = None
|
|
|
|
|
department: Optional[str] = None
|
|
|
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
class ContactMergeRequest(BaseModel):
|
|
|
|
|
source_contact_id: int = Field(..., gt=0)
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
class EmailCompanyResolve(BaseModel):
|
|
|
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
|
|
|
cvr_number: str = Field(..., pattern=r"^\d{8}$")
|
|
|
|
|
email: Optional[str] = None
|
|
|
|
|
phone: Optional[str] = None
|
|
|
|
|
address: Optional[str] = None
|
|
|
|
|
city: Optional[str] = None
|
|
|
|
|
postal_code: Optional[str] = None
|
|
|
|
|
website: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
CONTACT_MERGE_RELATIONS = (
|
|
|
|
|
("Firmaer", "contact_companies", "contact_id"),
|
|
|
|
|
("Sager", "sag_kontakter", "contact_id"),
|
|
|
|
|
("Opkald", "telefoni_opkald", "kontakt_id"),
|
|
|
|
|
("SMS", "sms_messages", "kontakt_id"),
|
|
|
|
|
("E-mails", "tticket_email_metadata", "matched_contact_id"),
|
|
|
|
|
("Tickets", "tticket_tickets", "contact_id"),
|
|
|
|
|
("Ticketrelationer", "tticket_contacts", "contact_id"),
|
|
|
|
|
("AnyDesk-sessioner", "anydesk_sessions", "contact_id"),
|
|
|
|
|
("Forsendelser", "fedex_shipments", "contact_id"),
|
|
|
|
|
("Hardware", "hardware_contacts", "contact_id"),
|
|
|
|
|
("Salgsmuligheder", "pipeline_opportunity_contacts", "contact_id"),
|
|
|
|
|
("Lokationer", "locations_contacts", "related_contact_id"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
def _exact_email_pattern(address: str) -> str:
|
|
|
|
|
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _plain_email_body(parsed: dict) -> str:
|
|
|
|
|
text = str(parsed.get("body_text") or "").strip()
|
|
|
|
|
if text:
|
|
|
|
|
return html.unescape(text).replace("\xa0", " ")
|
|
|
|
|
raw_html = str(parsed.get("body_html") or "")
|
|
|
|
|
raw_html = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw_html)
|
|
|
|
|
raw_html = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", raw_html)
|
|
|
|
|
return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _signature_lines(body: str) -> list[str]:
|
|
|
|
|
"""Normalize common Outlook/Markdown decoration without losing line structure."""
|
|
|
|
|
lines = []
|
|
|
|
|
for raw_line in body.splitlines():
|
|
|
|
|
line = re.sub(r"[*_`]+", "", raw_line)
|
|
|
|
|
line = re.sub(r"\s+", " ", line).strip(" \\|")
|
|
|
|
|
if line:
|
|
|
|
|
lines.append(line)
|
|
|
|
|
return lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _labelled_phone(lines: list[str], labels: str) -> Optional[str]:
|
|
|
|
|
phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}")
|
|
|
|
|
label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$")
|
|
|
|
|
for line in lines:
|
|
|
|
|
labelled = label_pattern.search(line)
|
|
|
|
|
if labelled:
|
|
|
|
|
phone = phone_pattern.search(labelled.group(1))
|
|
|
|
|
if phone:
|
|
|
|
|
return phone.group(0).strip()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]:
|
|
|
|
|
"""Conservative, review-only extraction from headers and labelled signature lines."""
|
|
|
|
|
body = _plain_email_body(parsed)
|
|
|
|
|
signature_lines = _signature_lines(body)
|
|
|
|
|
sender_email = str(parsed.get("sender_email") or "").strip().lower()
|
|
|
|
|
sender_name = str(parsed.get("sender_name") or "").strip()
|
|
|
|
|
parsed_sender_name, parsed_sender_address = parseaddr(sender_name)
|
|
|
|
|
if parsed_sender_name:
|
|
|
|
|
sender_name = parsed_sender_name
|
|
|
|
|
if not sender_email and parsed_sender_address:
|
|
|
|
|
sender_email = parsed_sender_address.strip().lower()
|
|
|
|
|
current_email = str(contact.get("email") or "").strip().lower()
|
|
|
|
|
candidates: dict[str, tuple[str, str]] = {}
|
|
|
|
|
|
|
|
|
|
# A received Outlook message normally represents the sender. If this looks
|
|
|
|
|
# like a sent message to the current contact, do not suggest our own sender.
|
|
|
|
|
recipient_text = str(parsed.get("recipient_email") or "").lower()
|
|
|
|
|
sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text
|
|
|
|
|
if sender_represents_contact and sender_email:
|
|
|
|
|
candidates["email"] = (sender_email, "Mailens afsender")
|
|
|
|
|
if sender_represents_contact and sender_name and "@" not in sender_name:
|
|
|
|
|
name_parts = sender_name.split()
|
|
|
|
|
if name_parts:
|
|
|
|
|
candidates["first_name"] = (name_parts[0], "Afsendernavn")
|
|
|
|
|
if len(name_parts) > 1:
|
|
|
|
|
candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn")
|
|
|
|
|
|
|
|
|
|
mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?")
|
|
|
|
|
phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte")
|
|
|
|
|
if mobile:
|
|
|
|
|
candidates["mobile"] = (mobile, "Mailens signatur")
|
|
|
|
|
if phone:
|
|
|
|
|
candidates["phone"] = (phone, "Mailens signatur")
|
|
|
|
|
|
|
|
|
|
for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")):
|
|
|
|
|
pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$")
|
|
|
|
|
for line in signature_lines:
|
|
|
|
|
match = pattern.search(line)
|
|
|
|
|
if match:
|
|
|
|
|
candidates[field] = (match.group(1).strip(), "Mailens signatur")
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Outlook signatures often put an unlabelled job title directly below the
|
|
|
|
|
# sender's name. This remains a review-only suggestion.
|
|
|
|
|
contact_full_name = " ".join(
|
|
|
|
|
part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part
|
|
|
|
|
)
|
|
|
|
|
signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2]
|
|
|
|
|
rejected_titles = {
|
|
|
|
|
"kind regards", "best regards", "regards", "med venlig hilsen",
|
|
|
|
|
"venlig hilsen", "mvh", "thanks", "thank you", "tak",
|
|
|
|
|
}
|
|
|
|
|
for signature_name in dict.fromkeys(signature_names):
|
|
|
|
|
for index in range(len(signature_lines) - 2, -1, -1):
|
|
|
|
|
if signature_lines[index].casefold() != signature_name:
|
|
|
|
|
continue
|
|
|
|
|
possible_title = signature_lines[index + 1]
|
|
|
|
|
if (
|
|
|
|
|
"title" not in candidates
|
|
|
|
|
and 2 <= len(possible_title) <= 100
|
|
|
|
|
and possible_title.casefold().rstrip(",.! ") not in rejected_titles
|
|
|
|
|
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title)
|
|
|
|
|
and not re.search(r"[@\d]|https?://", possible_title)
|
|
|
|
|
):
|
|
|
|
|
candidates["title"] = (possible_title, "Linjen under afsendernavnet")
|
|
|
|
|
break
|
|
|
|
|
if "title" in candidates:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
labels = {
|
|
|
|
|
"first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail",
|
|
|
|
|
"phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling",
|
|
|
|
|
}
|
|
|
|
|
suggestions = []
|
|
|
|
|
for field, (value, source) in candidates.items():
|
|
|
|
|
current = str(contact.get(field) or "").strip()
|
|
|
|
|
if value and current.casefold() != value.casefold():
|
|
|
|
|
suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source})
|
|
|
|
|
return suggestions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _company_from_email_body(body: str, contact_name: str = "") -> dict:
|
|
|
|
|
lines = _signature_lines(body)
|
|
|
|
|
joined = "\n".join(lines)
|
|
|
|
|
cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined)
|
|
|
|
|
cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else ""
|
|
|
|
|
if len(cvr_number) != 8:
|
|
|
|
|
cvr_number = ""
|
|
|
|
|
|
|
|
|
|
company_name = ""
|
|
|
|
|
labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined)
|
|
|
|
|
if labelled_name:
|
|
|
|
|
company_name = labelled_name.group(1).strip()
|
|
|
|
|
if not company_name:
|
|
|
|
|
for index, line in enumerate(lines):
|
|
|
|
|
if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)):
|
|
|
|
|
continue
|
|
|
|
|
if index:
|
|
|
|
|
candidate = lines[index - 1].strip()
|
|
|
|
|
if (
|
|
|
|
|
candidate.casefold() != contact_name.casefold()
|
|
|
|
|
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate)
|
|
|
|
|
and not re.search(r"[@+]|https?://", candidate)
|
|
|
|
|
):
|
|
|
|
|
company_name = candidate
|
|
|
|
|
break
|
|
|
|
|
return {"name": company_name or None, "cvr_number": cvr_number or None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/analyze-email")
|
|
|
|
|
async def analyze_email_for_new_contact(file: UploadFile = File(...)):
|
|
|
|
|
"""Return editable contact/company candidates without creating anything."""
|
|
|
|
|
filename = str(file.filename or "").lower()
|
|
|
|
|
if not filename.endswith((".eml", ".msg")):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
|
|
|
|
|
content = await file.read()
|
|
|
|
|
if not content or len(content) > 10 * 1024 * 1024:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
|
|
|
|
|
from app.services.email_service import EmailService
|
|
|
|
|
parser = EmailService()
|
|
|
|
|
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
|
|
|
|
|
if not parsed:
|
|
|
|
|
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
|
|
|
|
|
|
|
|
|
|
empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")}
|
|
|
|
|
suggestions = _contact_suggestions_from_email(empty_contact, parsed)
|
|
|
|
|
contact = {item["field"]: item["suggested"] for item in suggestions}
|
|
|
|
|
contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
|
|
|
|
|
company = _company_from_email_body(_plain_email_body(parsed), contact_name)
|
|
|
|
|
company["existing_id"] = None
|
|
|
|
|
company["existing_name"] = None
|
|
|
|
|
company["lookup_found"] = False
|
|
|
|
|
if company.get("cvr_number"):
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
"""SELECT id, name FROM customers
|
|
|
|
|
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
|
|
|
|
|
ORDER BY id LIMIT 1""",
|
|
|
|
|
(company["cvr_number"],),
|
|
|
|
|
)
|
|
|
|
|
if existing:
|
|
|
|
|
company["existing_id"] = existing.get("id")
|
|
|
|
|
company["existing_name"] = existing.get("name")
|
|
|
|
|
official = await get_cvr_service().lookup_by_cvr(company["cvr_number"])
|
|
|
|
|
if official:
|
|
|
|
|
company.update({
|
|
|
|
|
"name": official.get("name") or company.get("name"),
|
|
|
|
|
"address": official.get("address"),
|
|
|
|
|
"city": official.get("city"),
|
|
|
|
|
"postal_code": official.get("postal_code") or official.get("zipcode"),
|
|
|
|
|
"phone": official.get("phone"),
|
|
|
|
|
"email": official.get("email"),
|
|
|
|
|
"website": official.get("website"),
|
|
|
|
|
"status": official.get("status"),
|
|
|
|
|
"source": official.get("source"),
|
|
|
|
|
"lookup_found": True,
|
|
|
|
|
})
|
|
|
|
|
return {
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
"mail": {"subject": parsed.get("subject"), "sender_email": parsed.get("sender_email")},
|
|
|
|
|
"contact": contact,
|
|
|
|
|
"company": company,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/resolve-email-company")
|
|
|
|
|
async def resolve_email_company(payload: EmailCompanyResolve):
|
|
|
|
|
"""Reuse a CVR match or create the approved company exactly once."""
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
"""SELECT id, name, cvr_number FROM customers
|
|
|
|
|
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
|
|
|
|
|
ORDER BY id LIMIT 1""",
|
|
|
|
|
(payload.cvr_number,),
|
|
|
|
|
)
|
|
|
|
|
if existing:
|
|
|
|
|
return {**existing, "created": False}
|
|
|
|
|
created = await create_customer(CustomerCreate(
|
|
|
|
|
name=payload.name,
|
|
|
|
|
cvr_number=payload.cvr_number,
|
|
|
|
|
email=payload.email,
|
|
|
|
|
phone=payload.phone,
|
|
|
|
|
address=payload.address,
|
|
|
|
|
city=payload.city,
|
|
|
|
|
postal_code=payload.postal_code,
|
|
|
|
|
website=payload.website,
|
|
|
|
|
is_active=True,
|
|
|
|
|
))
|
|
|
|
|
return {**created, "created": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/{contact_id}/analyze-email")
|
|
|
|
|
async def analyze_contact_email(contact_id: int, file: UploadFile = File(...)):
|
|
|
|
|
"""Parse an Outlook email and return reviewable contact update suggestions."""
|
|
|
|
|
contact = execute_query_single(
|
|
|
|
|
"SELECT id, first_name, last_name, email, phone, mobile, title, department FROM contacts WHERE id = %s",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
)
|
|
|
|
|
if not contact:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
filename = str(file.filename or "").lower()
|
|
|
|
|
if not filename.endswith((".eml", ".msg")):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
|
|
|
|
|
content = await file.read()
|
|
|
|
|
if not content or len(content) > 10 * 1024 * 1024:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
|
|
|
|
|
|
|
|
|
|
from app.services.email_service import EmailService
|
|
|
|
|
parser = EmailService()
|
|
|
|
|
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
|
|
|
|
|
if not parsed:
|
|
|
|
|
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
|
|
|
|
|
suggestions = _contact_suggestions_from_email(contact, parsed)
|
|
|
|
|
return {
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
"mail": {"subject": parsed.get("subject"), "sender_name": parsed.get("sender_name"), "sender_email": parsed.get("sender_email")},
|
|
|
|
|
"suggestions": suggestions,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/emails")
|
|
|
|
|
async def get_contact_emails(
|
|
|
|
|
contact_id: int,
|
|
|
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
|
|
|
offset: int = Query(default=0, ge=0),
|
|
|
|
|
):
|
|
|
|
|
contact = execute_query_single("SELECT id, email FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
if not contact:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
address = str(contact.get("email") or "").strip().lower()
|
|
|
|
|
if not address:
|
|
|
|
|
return {"items": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
|
|
|
|
|
|
predicate = "em.deleted_at IS NULL AND LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc)) ~ %s"
|
|
|
|
|
params = (_exact_email_pattern(address),)
|
|
|
|
|
total = execute_query_single(f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", params)
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
|
|
|
|
|
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
|
|
|
|
|
em.linked_case_id, s.titel AS linked_case_title
|
|
|
|
|
FROM email_messages em
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
|
|
|
|
|
WHERE {predicate}
|
|
|
|
|
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
|
|
|
|
|
LIMIT %s OFFSET %s
|
|
|
|
|
""",
|
|
|
|
|
(*params, limit, offset),
|
|
|
|
|
) or []
|
|
|
|
|
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/cases")
|
|
|
|
|
async def get_contact_cases(
|
|
|
|
|
contact_id: int,
|
|
|
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
|
|
|
offset: int = Query(default=0, ge=0),
|
|
|
|
|
):
|
|
|
|
|
if not execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,)):
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
predicate = "sk.contact_id = %s AND sk.deleted_at IS NULL AND s.deleted_at IS NULL"
|
|
|
|
|
total = execute_query_single(
|
|
|
|
|
f"SELECT COUNT(*) AS count FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE {predicate}",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
)
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT s.id, s.titel, s.status, s.updated_at, s.customer_id,
|
|
|
|
|
cu.name AS customer_name,
|
|
|
|
|
s.ansvarlig_bruger_id,
|
|
|
|
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
|
|
|
|
|
FROM sag_kontakter sk
|
|
|
|
|
JOIN sag_sager s ON s.id = sk.sag_id
|
|
|
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
|
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
|
|
|
WHERE {predicate}
|
|
|
|
|
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
|
|
|
|
|
LIMIT %s OFFSET %s
|
|
|
|
|
""",
|
|
|
|
|
(contact_id, limit, offset),
|
|
|
|
|
) or []
|
|
|
|
|
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
|
|
|
|
|
|
|
|
|
|
|
2026-02-03 15:37:16 +01:00
|
|
|
class ContactCompanyLink(BaseModel):
|
|
|
|
|
customer_id: int
|
|
|
|
|
is_primary: bool = True
|
|
|
|
|
role: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
|
2025-12-22 15:48:21 +01:00
|
|
|
@router.get("/contacts-debug")
|
|
|
|
|
async def debug_contacts():
|
|
|
|
|
"""Debug endpoint: Check contact-company links"""
|
|
|
|
|
try:
|
|
|
|
|
# Count links
|
|
|
|
|
links = execute_query("SELECT COUNT(*) as total FROM contact_companies")
|
|
|
|
|
|
|
|
|
|
# Get sample with links
|
|
|
|
|
sample = execute_query("""
|
|
|
|
|
SELECT
|
|
|
|
|
c.id, c.first_name, c.last_name,
|
|
|
|
|
COUNT(cc.customer_id) as company_count,
|
|
|
|
|
ARRAY_AGG(cu.name) as company_names
|
|
|
|
|
FROM contacts c
|
|
|
|
|
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
|
|
|
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
GROUP BY c.id, c.first_name, c.last_name
|
|
|
|
|
HAVING COUNT(cc.customer_id) > 0
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
# Test the actual query used in get_contacts
|
|
|
|
|
test_query = """
|
|
|
|
|
SELECT
|
|
|
|
|
c.id, c.first_name, c.last_name,
|
|
|
|
|
COUNT(DISTINCT cc.customer_id) as company_count,
|
|
|
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
|
|
|
|
|
FROM contacts c
|
|
|
|
|
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
|
|
|
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
GROUP BY c.id, c.first_name, c.last_name
|
|
|
|
|
ORDER BY c.last_name, c.first_name
|
|
|
|
|
LIMIT 10
|
|
|
|
|
"""
|
|
|
|
|
test_result = execute_query(test_query)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"total_links": links[0]['total'] if links else 0,
|
|
|
|
|
"sample_contacts_with_companies": sample or [],
|
|
|
|
|
"test_query_result": test_result or [],
|
|
|
|
|
"note": "If company_count is 0, the JOIN might not be working"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Debug failed: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 16:38:08 +01:00
|
|
|
@router.get("/contacts")
|
|
|
|
|
async def get_contacts(
|
|
|
|
|
search: Optional[str] = None,
|
|
|
|
|
customer_id: Optional[int] = None,
|
|
|
|
|
is_active: Optional[bool] = None,
|
|
|
|
|
limit: int = Query(default=100, le=1000),
|
|
|
|
|
offset: int = Query(default=0, ge=0)
|
|
|
|
|
):
|
|
|
|
|
"""Get all contacts with optional filtering"""
|
|
|
|
|
try:
|
|
|
|
|
where_clauses = []
|
|
|
|
|
params = []
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
normalized_search = " ".join(str(search or "").split())
|
|
|
|
|
if normalized_search:
|
|
|
|
|
for token in normalized_search.split(" "):
|
|
|
|
|
token_clause = """
|
|
|
|
|
(
|
|
|
|
|
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s
|
|
|
|
|
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s
|
|
|
|
|
OR COALESCE(c.email, '') ILIKE %s
|
|
|
|
|
OR COALESCE(c.title, '') ILIKE %s
|
|
|
|
|
OR COALESCE(c.department, '') ILIKE %s
|
|
|
|
|
OR COALESCE(c.user_company, '') ILIKE %s
|
|
|
|
|
OR EXISTS (
|
|
|
|
|
SELECT 1 FROM contact_companies cc2
|
|
|
|
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
|
|
|
|
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
|
|
|
|
|
)
|
2026-06-11 01:12:25 +02:00
|
|
|
"""
|
2026-08-28 20:49:55 +02:00
|
|
|
token_params: list[Any] = [f"%{token}%"] * 7
|
|
|
|
|
digits = "".join(ch for ch in token if ch.isdigit())
|
|
|
|
|
if len(digits) >= 2:
|
|
|
|
|
token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
|
|
|
|
|
token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
|
|
|
|
|
token_params.extend([f"%{digits}%", f"%{digits}%"])
|
|
|
|
|
token_clause += ")"
|
|
|
|
|
where_clauses.append(token_clause)
|
|
|
|
|
params.extend(token_params)
|
2025-12-17 16:38:08 +01:00
|
|
|
|
|
|
|
|
if is_active is not None:
|
2025-12-22 16:04:49 +01:00
|
|
|
where_clauses.append("c.is_active = %s")
|
2025-12-17 16:38:08 +01:00
|
|
|
params.append(is_active)
|
2026-05-16 10:28:05 +02:00
|
|
|
|
|
|
|
|
if customer_id is not None:
|
|
|
|
|
where_clauses.append(
|
|
|
|
|
"EXISTS (SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id AND cc.customer_id = %s)"
|
|
|
|
|
)
|
|
|
|
|
params.append(customer_id)
|
2025-12-17 16:38:08 +01:00
|
|
|
|
|
|
|
|
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
|
|
|
|
|
2026-05-16 10:28:05 +02:00
|
|
|
# Count total (distinct id for consistency with optional filters/joins)
|
|
|
|
|
count_query = f"SELECT COUNT(DISTINCT c.id) as count FROM contacts c {where_sql}"
|
2025-12-17 16:38:08 +01:00
|
|
|
count_result = execute_query(count_query, tuple(params))
|
|
|
|
|
total = count_result[0]['count'] if count_result else 0
|
|
|
|
|
|
2026-05-16 10:28:05 +02:00
|
|
|
# Step 1: Fetch contacts only (stable pagination)
|
2026-08-28 20:49:55 +02:00
|
|
|
rank_order_sql = ""
|
|
|
|
|
rank_params: list[Any] = []
|
|
|
|
|
if normalized_search:
|
|
|
|
|
rank_sql = """
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(CONCAT_WS(' ', c.first_name, c.last_name)) = LOWER(%s) THEN 0
|
|
|
|
|
WHEN LOWER(CONCAT_WS(' ', c.last_name, c.first_name)) = LOWER(%s) THEN 1
|
|
|
|
|
WHEN CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s THEN 2
|
|
|
|
|
WHEN CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s THEN 3
|
|
|
|
|
WHEN COALESCE(c.email, '') ILIKE %s THEN 4
|
|
|
|
|
ELSE 5
|
|
|
|
|
END
|
|
|
|
|
"""
|
|
|
|
|
rank_order_sql = f"{rank_sql},"
|
|
|
|
|
rank_params = [normalized_search, normalized_search, f"{normalized_search}%", f"{normalized_search}%", f"{normalized_search}%"]
|
|
|
|
|
|
2026-05-16 10:28:05 +02:00
|
|
|
contacts_query = f"""
|
2025-12-17 16:38:08 +01:00
|
|
|
SELECT
|
2025-12-22 15:48:21 +01:00
|
|
|
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
2026-05-16 10:28:05 +02:00
|
|
|
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
|
2025-12-22 15:48:21 +01:00
|
|
|
FROM contacts c
|
2025-12-17 16:38:08 +01:00
|
|
|
{where_sql}
|
2026-08-28 20:49:55 +02:00
|
|
|
ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id
|
2025-12-17 16:38:08 +01:00
|
|
|
LIMIT %s OFFSET %s
|
|
|
|
|
"""
|
2026-05-16 10:28:05 +02:00
|
|
|
contacts_params = list(params)
|
2026-08-28 20:49:55 +02:00
|
|
|
contacts_params.extend(rank_params)
|
2026-05-16 10:28:05 +02:00
|
|
|
contacts_params.extend([limit, offset])
|
|
|
|
|
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
|
|
|
|
|
|
|
|
|
|
# Step 2: Enrich page contacts with aggregated company info
|
|
|
|
|
if contacts:
|
|
|
|
|
contact_ids = [row["id"] for row in contacts]
|
|
|
|
|
placeholders = ",".join(["%s"] * len(contact_ids))
|
|
|
|
|
companies_query = f"""
|
|
|
|
|
SELECT
|
|
|
|
|
cc.contact_id,
|
|
|
|
|
COUNT(DISTINCT cc.customer_id) AS company_count,
|
|
|
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
WHERE cc.contact_id IN ({placeholders})
|
|
|
|
|
GROUP BY cc.contact_id
|
|
|
|
|
"""
|
|
|
|
|
company_rows = execute_query(companies_query, tuple(contact_ids)) or []
|
|
|
|
|
company_map = {row["contact_id"]: row for row in company_rows}
|
|
|
|
|
|
|
|
|
|
for contact in contacts:
|
|
|
|
|
info = company_map.get(contact["id"])
|
|
|
|
|
contact["company_count"] = int(info["company_count"]) if info and info.get("company_count") is not None else 0
|
|
|
|
|
contact["company_names"] = info.get("company_names") if info and info.get("company_names") else []
|
2025-12-17 16:38:08 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"total": total,
|
|
|
|
|
"contacts": contacts,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
"offset": offset
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get contacts: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
@router.post("/contacts", status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_contact(contact: ContactCreate):
|
|
|
|
|
"""
|
|
|
|
|
Create a new basic contact
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Check if email exists
|
|
|
|
|
if contact.email:
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
"SELECT id FROM contacts WHERE email = %s",
|
|
|
|
|
(contact.email,)
|
|
|
|
|
)
|
|
|
|
|
if existing:
|
|
|
|
|
# Return existing contact if found? Or error?
|
|
|
|
|
# For now, let's error to be safe, or just return it?
|
|
|
|
|
# User prompted "Smart Create", implies if it exists, use it?
|
|
|
|
|
# But safer to say "Email already exists"
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
insert_query = """
|
2026-07-10 08:10:31 +02:00
|
|
|
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
2026-01-10 21:09:29 +01:00
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
contact_id = execute_insert(
|
|
|
|
|
insert_query,
|
2026-07-10 08:10:31 +02:00
|
|
|
(
|
|
|
|
|
contact.first_name,
|
|
|
|
|
contact.last_name,
|
|
|
|
|
contact.email,
|
|
|
|
|
contact.phone,
|
|
|
|
|
contact.mobile,
|
|
|
|
|
contact.title,
|
|
|
|
|
contact.department,
|
|
|
|
|
contact.is_active,
|
|
|
|
|
)
|
2026-01-10 21:09:29 +01:00
|
|
|
)
|
|
|
|
|
|
2026-07-10 08:10:31 +02:00
|
|
|
company_ids = []
|
|
|
|
|
if contact.company_ids:
|
|
|
|
|
company_ids.extend(int(company_id) for company_id in contact.company_ids if company_id)
|
|
|
|
|
if contact.company_id and contact.company_id not in company_ids:
|
|
|
|
|
company_ids.append(int(contact.company_id))
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
# Link to company if provided
|
2026-07-10 08:10:31 +02:00
|
|
|
for idx, company_id in enumerate(company_ids):
|
2026-01-10 21:09:29 +01:00
|
|
|
try:
|
|
|
|
|
link_query = """
|
|
|
|
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
|
|
|
|
VALUES (%s, %s, true, 'primary')
|
2026-02-03 15:37:16 +01:00
|
|
|
ON CONFLICT (contact_id, customer_id)
|
|
|
|
|
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
|
|
|
|
RETURNING id
|
2026-01-10 21:09:29 +01:00
|
|
|
"""
|
2026-07-10 08:10:31 +02:00
|
|
|
execute_insert(
|
|
|
|
|
link_query,
|
|
|
|
|
(
|
|
|
|
|
contact_id,
|
|
|
|
|
company_id,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
if idx > 0 or not contact.is_primary or contact.role:
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE contact_companies
|
|
|
|
|
SET is_primary = %s,
|
|
|
|
|
role = COALESCE(%s, role)
|
|
|
|
|
WHERE contact_id = %s AND customer_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(idx == 0 and contact.is_primary, contact.role, contact_id, company_id),
|
|
|
|
|
)
|
2026-01-10 21:09:29 +01:00
|
|
|
except Exception as e:
|
2026-07-10 08:10:31 +02:00
|
|
|
logger.error(f"Failed to link new contact {contact_id} to company {company_id}: {e}")
|
2026-01-10 21:09:29 +01:00
|
|
|
# Don't fail the whole request, just log it
|
|
|
|
|
|
|
|
|
|
return await get_contact(contact_id)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to create contact: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
def _contact_merge_counts(contact_id: int) -> list[dict]:
|
|
|
|
|
counts = []
|
|
|
|
|
for label, table, column in CONTACT_MERGE_RELATIONS:
|
|
|
|
|
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
|
|
|
|
|
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
|
|
|
|
|
conversation_row = execute_query_single(
|
|
|
|
|
"""
|
|
|
|
|
SELECT COUNT(DISTINCT conversation.id)::int AS count
|
|
|
|
|
FROM conversations conversation
|
|
|
|
|
JOIN contact_companies cc ON cc.customer_id = conversation.customer_id
|
|
|
|
|
WHERE cc.contact_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
) or {}
|
|
|
|
|
counts.append({
|
|
|
|
|
"key": "conversations_via_company",
|
|
|
|
|
"label": "Samtaler via firma",
|
|
|
|
|
"count": int(conversation_row.get("count") or 0),
|
|
|
|
|
"preserved_via": "company",
|
|
|
|
|
})
|
|
|
|
|
return counts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/merge-preview")
|
|
|
|
|
async def preview_contact_merge(contact_id: int, source_contact_id: int = Query(..., gt=0)):
|
|
|
|
|
if contact_id == source_contact_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
|
|
|
|
|
target = execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
source = execute_query_single("SELECT * FROM contacts WHERE id = %s", (source_contact_id,))
|
|
|
|
|
if not target or not source:
|
|
|
|
|
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
|
|
|
|
|
relations = _contact_merge_counts(source_contact_id)
|
|
|
|
|
return {
|
|
|
|
|
"target": target,
|
|
|
|
|
"source": source,
|
|
|
|
|
"relations": relations,
|
|
|
|
|
"total_relations": sum(item["count"] for item in relations),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/{contact_id}/merge")
|
|
|
|
|
async def merge_contact(contact_id: int, request: ContactMergeRequest):
|
|
|
|
|
source_id = int(request.source_contact_id)
|
|
|
|
|
if contact_id == source_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
|
|
|
|
|
|
|
|
|
|
conn = get_db_connection()
|
|
|
|
|
try:
|
|
|
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
|
|
|
cursor.execute("SELECT * FROM contacts WHERE id IN (%s, %s) FOR UPDATE", (contact_id, source_id))
|
|
|
|
|
rows = cursor.fetchall()
|
|
|
|
|
by_id = {int(row["id"]): dict(row) for row in rows}
|
|
|
|
|
target = by_id.get(contact_id)
|
|
|
|
|
source = by_id.get(source_id)
|
|
|
|
|
if not target or not source:
|
|
|
|
|
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
|
|
|
|
|
|
|
|
|
|
moved = {}
|
|
|
|
|
|
|
|
|
|
# Preserve missing master data on the target contact.
|
|
|
|
|
merge_fields = ("first_name", "last_name", "email", "phone", "mobile", "title", "department", "user_company")
|
|
|
|
|
assignments = []
|
|
|
|
|
values = []
|
|
|
|
|
for field in merge_fields:
|
|
|
|
|
target_value = target.get(field)
|
|
|
|
|
source_value = source.get(field)
|
|
|
|
|
if (target_value is None or str(target_value).strip() == "") and source_value not in (None, ""):
|
|
|
|
|
assignments.append(f"{field} = %s")
|
|
|
|
|
values.append(source_value)
|
|
|
|
|
if assignments:
|
|
|
|
|
values.append(contact_id)
|
|
|
|
|
cursor.execute(f"UPDATE contacts SET {', '.join(assignments)}, updated_at = NOW() WHERE id = %s", tuple(values))
|
|
|
|
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
|
|
|
|
|
SELECT %s, customer_id, is_primary, role, notes
|
|
|
|
|
FROM contact_companies WHERE contact_id = %s
|
|
|
|
|
ON CONFLICT (contact_id, customer_id) DO UPDATE SET
|
|
|
|
|
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
|
|
|
|
|
role = COALESCE(contact_companies.role, EXCLUDED.role),
|
|
|
|
|
notes = COALESCE(contact_companies.notes, EXCLUDED.notes)
|
|
|
|
|
""",
|
|
|
|
|
(contact_id, source_id),
|
|
|
|
|
)
|
|
|
|
|
cursor.execute("SELECT COUNT(*)::int AS count FROM contact_companies WHERE contact_id = %s", (source_id,))
|
|
|
|
|
moved["contact_companies"] = int(cursor.fetchone()["count"] or 0)
|
|
|
|
|
cursor.execute("DELETE FROM contact_companies WHERE contact_id = %s", (source_id,))
|
|
|
|
|
|
|
|
|
|
unique_relations = (
|
|
|
|
|
("hardware_contacts", "contact_id", "hardware_id", "TRUE", "TRUE"),
|
|
|
|
|
("pipeline_opportunity_contacts", "contact_id", "opportunity_id", "TRUE", "TRUE"),
|
|
|
|
|
("tticket_contacts", "contact_id", "ticket_id", "TRUE", "TRUE"),
|
|
|
|
|
("locations_contacts", "related_contact_id", "location_id", "src.deleted_at IS NULL", "dst.deleted_at IS NULL"),
|
|
|
|
|
)
|
|
|
|
|
for table, column, owner_column, source_clause, target_clause in unique_relations:
|
|
|
|
|
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
|
|
|
|
|
moved[table] = int(cursor.fetchone()["count"] or 0)
|
|
|
|
|
cursor.execute(
|
|
|
|
|
f"DELETE FROM {table} src WHERE src.{column} = %s AND {source_clause} "
|
|
|
|
|
f"AND EXISTS (SELECT 1 FROM {table} dst WHERE dst.{column} = %s "
|
|
|
|
|
f"AND dst.{owner_column} = src.{owner_column} AND {target_clause})",
|
|
|
|
|
(source_id, contact_id),
|
|
|
|
|
)
|
|
|
|
|
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
|
|
|
|
|
|
|
|
|
# Avoid duplicate active case-contact rows, while retaining roles and history.
|
|
|
|
|
cursor.execute("SELECT COUNT(*)::int AS count FROM sag_kontakter WHERE contact_id = %s", (source_id,))
|
|
|
|
|
moved["sag_kontakter"] = int(cursor.fetchone()["count"] or 0)
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"""
|
|
|
|
|
DELETE FROM sag_kontakter src
|
|
|
|
|
WHERE src.contact_id = %s AND src.deleted_at IS NULL
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1 FROM sag_kontakter dst
|
|
|
|
|
WHERE dst.contact_id = %s AND dst.sag_id = src.sag_id AND dst.deleted_at IS NULL
|
|
|
|
|
)
|
|
|
|
|
""",
|
|
|
|
|
(source_id, contact_id),
|
|
|
|
|
)
|
|
|
|
|
cursor.execute("UPDATE sag_kontakter SET contact_id = %s WHERE contact_id = %s", (contact_id, source_id))
|
|
|
|
|
|
|
|
|
|
direct_relations = (
|
|
|
|
|
("telefoni_opkald", "kontakt_id"),
|
|
|
|
|
("sms_messages", "kontakt_id"),
|
|
|
|
|
("tticket_email_metadata", "matched_contact_id"),
|
|
|
|
|
("tticket_tickets", "contact_id"),
|
|
|
|
|
("anydesk_sessions", "contact_id"),
|
|
|
|
|
("fedex_shipments", "contact_id"),
|
|
|
|
|
)
|
|
|
|
|
for table, column in direct_relations:
|
|
|
|
|
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
|
|
|
|
|
moved[table] = int(cursor.fetchone()["count"] or 0)
|
|
|
|
|
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
|
|
|
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
|
|
|
|
|
VALUES (%s, %s, %s::jsonb, %s::jsonb)
|
|
|
|
|
""",
|
|
|
|
|
(contact_id, source_id, json.dumps(source, default=str), json.dumps(moved)),
|
|
|
|
|
)
|
|
|
|
|
cursor.execute("DELETE FROM contacts WHERE id = %s", (source_id,))
|
|
|
|
|
conn.commit()
|
|
|
|
|
return {"success": True, "target_contact_id": contact_id, "merged_contact_id": source_id, "moved_relations": moved}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
conn.rollback()
|
|
|
|
|
raise
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
conn.rollback()
|
|
|
|
|
logger.error("Failed merging contact %s into %s: %s", source_id, contact_id, exc, exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Kontakterne kunne ikke flettes. Ingen ændringer blev gemt.")
|
|
|
|
|
finally:
|
|
|
|
|
release_db_connection(conn)
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 16:38:08 +01:00
|
|
|
@router.get("/contacts/{contact_id}")
|
|
|
|
|
async def get_contact(contact_id: int):
|
2025-12-22 16:40:49 +01:00
|
|
|
"""Get a single contact by ID with linked companies"""
|
2025-12-17 16:38:08 +01:00
|
|
|
try:
|
2025-12-22 16:40:49 +01:00
|
|
|
# Get contact info
|
2025-12-17 16:38:08 +01:00
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
id, first_name, last_name, email, phone, mobile,
|
2025-12-22 16:40:49 +01:00
|
|
|
title, department, is_active, user_company, vtiger_id,
|
2025-12-17 16:38:08 +01:00
|
|
|
created_at, updated_at
|
|
|
|
|
FROM contacts
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
"""
|
|
|
|
|
contacts = execute_query(query, (contact_id,))
|
|
|
|
|
|
|
|
|
|
if not contacts:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
2025-12-22 16:40:49 +01:00
|
|
|
contact = contacts[0]
|
|
|
|
|
|
|
|
|
|
# Get linked companies
|
|
|
|
|
companies_query = """
|
|
|
|
|
SELECT
|
|
|
|
|
cu.id, cu.name, cu.cvr_number,
|
|
|
|
|
cc.is_primary, cc.role, cc.notes
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
WHERE cc.contact_id = %s
|
|
|
|
|
ORDER BY cc.is_primary DESC, cu.name
|
|
|
|
|
"""
|
|
|
|
|
companies = execute_query(companies_query, (contact_id,))
|
|
|
|
|
|
|
|
|
|
contact['companies'] = companies or []
|
|
|
|
|
|
|
|
|
|
return contact
|
2025-12-17 16:38:08 +01:00
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get contact {contact_id}: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-02-03 15:37:16 +01:00
|
|
|
|
|
|
|
|
|
2026-02-06 10:47:14 +01:00
|
|
|
@router.put("/contacts/{contact_id}")
|
|
|
|
|
async def update_contact(contact_id: int, contact_data: ContactUpdate):
|
|
|
|
|
"""Update a contact"""
|
|
|
|
|
try:
|
|
|
|
|
# Ensure contact exists
|
|
|
|
|
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
if not contact:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
# Build update query dynamically
|
|
|
|
|
update_fields = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
for field, value in contact_data.model_dump(exclude_unset=True).items():
|
|
|
|
|
update_fields.append(f"{field} = %s")
|
|
|
|
|
params.append(value)
|
|
|
|
|
|
|
|
|
|
if not update_fields:
|
|
|
|
|
# No fields to update
|
|
|
|
|
return await get_contact(contact_id)
|
|
|
|
|
|
|
|
|
|
params.append(contact_id)
|
|
|
|
|
|
|
|
|
|
update_query = f"""
|
|
|
|
|
UPDATE contacts
|
|
|
|
|
SET {', '.join(update_fields)}, updated_at = NOW()
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
execute_query(update_query, tuple(params))
|
|
|
|
|
|
|
|
|
|
return await get_contact(contact_id)
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to update contact {contact_id}: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-02-03 15:37:16 +01:00
|
|
|
@router.post("/contacts/{contact_id}/companies")
|
|
|
|
|
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
|
|
|
|
"""Link a contact to a company"""
|
|
|
|
|
try:
|
|
|
|
|
# Ensure contact exists
|
|
|
|
|
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
if not contact:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
# Ensure customer exists
|
|
|
|
|
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,))
|
|
|
|
|
if not customer:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
|
|
|
|
ON CONFLICT (contact_id, customer_id)
|
|
|
|
|
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role))
|
|
|
|
|
|
|
|
|
|
return {"message": "Contact linked to company successfully"}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to link contact to company: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-02-11 23:51:21 +01:00
|
|
|
|
|
|
|
|
|
2026-05-04 16:24:38 +02:00
|
|
|
@router.post("/contacts/admin/backfill-company-links")
|
|
|
|
|
async def backfill_contact_company_links(dry_run: bool = Query(default=True)):
|
|
|
|
|
"""
|
|
|
|
|
Backfill missing contact_companies links by matching contacts.user_company to customers.name.
|
|
|
|
|
|
|
|
|
|
- Uses case-insensitive trimmed exact name matching
|
|
|
|
|
- Picks lowest customer ID if duplicate customer names exist
|
|
|
|
|
- Idempotent: will not create duplicate links
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Contacts that have a company name on the contact row.
|
|
|
|
|
contacts_with_company = execute_query_single(
|
|
|
|
|
"""
|
|
|
|
|
SELECT COUNT(*)::int AS count
|
|
|
|
|
FROM contacts c
|
|
|
|
|
WHERE c.user_company IS NOT NULL
|
|
|
|
|
AND TRIM(c.user_company) <> ''
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Contacts where the company name can be matched to a customer record.
|
|
|
|
|
matchable = execute_query_single(
|
|
|
|
|
"""
|
|
|
|
|
WITH company_match AS (
|
|
|
|
|
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
|
|
|
|
|
FROM customers
|
|
|
|
|
GROUP BY LOWER(TRIM(name))
|
|
|
|
|
)
|
|
|
|
|
SELECT COUNT(DISTINCT c.id)::int AS count
|
|
|
|
|
FROM contacts c
|
|
|
|
|
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
|
|
|
|
|
WHERE c.user_company IS NOT NULL
|
|
|
|
|
AND TRIM(c.user_company) <> ''
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Contacts with no links at all (often the primary symptom).
|
|
|
|
|
unlinked = execute_query_single(
|
|
|
|
|
"""
|
|
|
|
|
SELECT COUNT(*)::int AS count
|
|
|
|
|
FROM contacts c
|
|
|
|
|
WHERE c.user_company IS NOT NULL
|
|
|
|
|
AND TRIM(c.user_company) <> ''
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
return {
|
|
|
|
|
"dry_run": True,
|
|
|
|
|
"contacts_with_user_company": (contacts_with_company or {}).get("count", 0),
|
|
|
|
|
"matchable_contacts": (matchable or {}).get("count", 0),
|
|
|
|
|
"unlinked_contacts": (unlinked or {}).get("count", 0),
|
|
|
|
|
"message": "Dry run complete. Re-run with dry_run=false to insert links.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inserted = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
WITH company_match AS (
|
|
|
|
|
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
|
|
|
|
|
FROM customers
|
|
|
|
|
GROUP BY LOWER(TRIM(name))
|
|
|
|
|
),
|
|
|
|
|
candidates AS (
|
|
|
|
|
SELECT
|
|
|
|
|
c.id AS contact_id,
|
|
|
|
|
cm.customer_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN EXISTS (
|
|
|
|
|
SELECT 1 FROM contact_companies cc1
|
|
|
|
|
WHERE cc1.contact_id = c.id
|
|
|
|
|
) THEN FALSE
|
|
|
|
|
ELSE TRUE
|
|
|
|
|
END AS is_primary
|
|
|
|
|
FROM contacts c
|
|
|
|
|
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
|
|
|
|
|
WHERE c.user_company IS NOT NULL
|
|
|
|
|
AND TRIM(c.user_company) <> ''
|
|
|
|
|
)
|
|
|
|
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
|
|
|
|
SELECT contact_id, customer_id, is_primary, 'inferred_user_company'
|
|
|
|
|
FROM candidates c
|
|
|
|
|
WHERE NOT EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
WHERE cc.contact_id = c.contact_id
|
|
|
|
|
AND cc.customer_id = c.customer_id
|
|
|
|
|
)
|
|
|
|
|
RETURNING contact_id, customer_id, is_primary
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
inserted_count = len(inserted or [])
|
|
|
|
|
logger.info("✅ Contact-company backfill inserted %s link(s)", inserted_count)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"dry_run": False,
|
|
|
|
|
"inserted": inserted_count,
|
|
|
|
|
"sample": (inserted or [])[:20],
|
|
|
|
|
"message": "Backfill completed",
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed backfill_contact_company_links: %s", e, exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
@router.get("/contacts/{contact_id}/related-contacts")
|
|
|
|
|
async def get_related_contacts(contact_id: int):
|
|
|
|
|
"""Get contacts from the same companies as the contact (excluding itself)."""
|
|
|
|
|
try:
|
|
|
|
|
customer_ids = get_contact_customer_ids(contact_id)
|
|
|
|
|
if not customer_ids:
|
|
|
|
|
return {"contacts": []}
|
|
|
|
|
|
|
|
|
|
placeholders = ",".join(["%s"] * len(customer_ids))
|
|
|
|
|
query = f"""
|
|
|
|
|
SELECT
|
|
|
|
|
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
|
|
|
|
c.title, c.department, c.is_active, c.vtiger_id,
|
|
|
|
|
c.created_at, c.updated_at,
|
|
|
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
|
|
|
|
|
FROM contacts c
|
|
|
|
|
JOIN contact_companies cc ON c.id = cc.contact_id
|
|
|
|
|
JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
WHERE cc.customer_id IN ({placeholders}) AND c.id <> %s
|
|
|
|
|
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
|
|
|
|
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
|
|
|
|
|
ORDER BY c.last_name, c.first_name
|
|
|
|
|
"""
|
|
|
|
|
params = tuple(customer_ids + [contact_id])
|
|
|
|
|
results = execute_query(query, params) or []
|
|
|
|
|
return {"contacts": results}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get related contacts for {contact_id}: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
@router.get("/contacts/{contact_id}/cases")
|
|
|
|
|
async def get_contact_cases(contact_id: int):
|
|
|
|
|
"""Get cases linked directly to a contact and cases from the contact's primary company."""
|
|
|
|
|
try:
|
|
|
|
|
contact_row = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
c.id,
|
|
|
|
|
(
|
|
|
|
|
SELECT cu.id
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
JOIN customers cu ON cu.id = cc.customer_id
|
|
|
|
|
WHERE cc.contact_id = c.id
|
|
|
|
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) AS company_id,
|
|
|
|
|
(
|
|
|
|
|
SELECT cu.name
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
JOIN customers cu ON cu.id = cc.customer_id
|
|
|
|
|
WHERE cc.contact_id = c.id
|
|
|
|
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) AS company_name
|
|
|
|
|
FROM contacts c
|
|
|
|
|
WHERE c.id = %s
|
|
|
|
|
""",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not contact_row:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
company_id = contact_row[0].get("company_id")
|
|
|
|
|
|
|
|
|
|
contact_cases = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.titel,
|
|
|
|
|
s.status,
|
|
|
|
|
s.customer_id,
|
|
|
|
|
cu.name AS customer_name,
|
|
|
|
|
s.created_at,
|
|
|
|
|
s.updated_at
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
|
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
|
|
|
WHERE sk.contact_id = %s
|
|
|
|
|
AND s.deleted_at IS NULL
|
|
|
|
|
AND sk.deleted_at IS NULL
|
|
|
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
company_cases = []
|
|
|
|
|
if company_id:
|
|
|
|
|
company_cases = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.titel,
|
|
|
|
|
s.status,
|
|
|
|
|
s.customer_id,
|
|
|
|
|
cu.name AS customer_name,
|
|
|
|
|
s.created_at,
|
|
|
|
|
s.updated_at
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
|
|
|
WHERE s.customer_id = %s
|
|
|
|
|
AND s.deleted_at IS NULL
|
|
|
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""",
|
|
|
|
|
(company_id,),
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"contact": {
|
|
|
|
|
"id": contact_row[0]["id"],
|
|
|
|
|
"company_id": company_id,
|
|
|
|
|
"company_name": contact_row[0].get("company_name"),
|
|
|
|
|
},
|
|
|
|
|
"contact_cases": contact_cases,
|
|
|
|
|
"company_cases": company_cases,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get cases for contact {contact_id}: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/case-context")
|
|
|
|
|
async def get_contact_case_context(contact_id: int):
|
|
|
|
|
"""Get case suggestions for a contact: contact cases, company cases and related contacts."""
|
|
|
|
|
try:
|
|
|
|
|
contact_rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
c.id,
|
|
|
|
|
c.first_name,
|
|
|
|
|
c.last_name,
|
|
|
|
|
c.email,
|
|
|
|
|
c.phone,
|
|
|
|
|
c.mobile,
|
|
|
|
|
c.title,
|
|
|
|
|
c.department,
|
|
|
|
|
c.is_active,
|
|
|
|
|
c.created_at,
|
|
|
|
|
c.updated_at,
|
|
|
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
|
|
|
FROM contacts c
|
|
|
|
|
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
|
|
|
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
WHERE c.id = %s
|
|
|
|
|
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
|
|
|
""",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
) or []
|
|
|
|
|
if not contact_rows:
|
|
|
|
|
return {"contact_cases": [], "company_cases": [], "related_contacts": []}
|
|
|
|
|
|
|
|
|
|
customer_ids = get_contact_customer_ids(contact_id)
|
|
|
|
|
placeholders = ",".join(["%s"] * len(customer_ids)) if customer_ids else ""
|
|
|
|
|
|
|
|
|
|
contact_cases = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.titel,
|
|
|
|
|
s.status,
|
|
|
|
|
s.customer_id,
|
|
|
|
|
cu.name AS customer_name,
|
|
|
|
|
s.created_at,
|
|
|
|
|
s.updated_at
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
|
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
|
|
|
WHERE sk.contact_id = %s
|
|
|
|
|
AND s.deleted_at IS NULL
|
|
|
|
|
AND sk.deleted_at IS NULL
|
|
|
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""",
|
|
|
|
|
(contact_id,),
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
company_cases = []
|
|
|
|
|
related_contacts = []
|
|
|
|
|
if customer_ids:
|
|
|
|
|
company_cases = execute_query(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.titel,
|
|
|
|
|
s.status,
|
|
|
|
|
s.customer_id,
|
|
|
|
|
cu.name AS customer_name,
|
|
|
|
|
s.created_at,
|
|
|
|
|
s.updated_at
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
|
|
|
WHERE s.customer_id IN ({placeholders})
|
|
|
|
|
AND s.deleted_at IS NULL
|
|
|
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""",
|
|
|
|
|
tuple(customer_ids),
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
related_contacts = execute_query(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT
|
|
|
|
|
c.id,
|
|
|
|
|
c.first_name,
|
|
|
|
|
c.last_name,
|
|
|
|
|
c.email,
|
|
|
|
|
c.phone,
|
|
|
|
|
c.mobile,
|
|
|
|
|
c.title,
|
|
|
|
|
c.department,
|
|
|
|
|
c.is_active,
|
|
|
|
|
c.created_at,
|
|
|
|
|
c.updated_at,
|
|
|
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
|
|
|
FROM contacts c
|
|
|
|
|
JOIN contact_companies cc ON c.id = cc.contact_id
|
|
|
|
|
JOIN customers cu ON cc.customer_id = cu.id
|
|
|
|
|
WHERE cc.customer_id IN ({placeholders})
|
|
|
|
|
AND c.id <> %s
|
|
|
|
|
AND c.is_active = TRUE
|
|
|
|
|
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
|
|
|
ORDER BY c.last_name, c.first_name
|
|
|
|
|
LIMIT 10
|
|
|
|
|
""",
|
|
|
|
|
tuple(customer_ids + [contact_id]),
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"contact_cases": contact_cases,
|
|
|
|
|
"company_cases": company_cases,
|
|
|
|
|
"related_contacts": related_contacts,
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get case context for contact {contact_id}: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
@router.get("/contacts/{contact_id}/subscriptions")
|
|
|
|
|
async def get_contact_subscriptions(contact_id: int):
|
|
|
|
|
customer_id = get_primary_customer_id(contact_id)
|
|
|
|
|
if not customer_id:
|
|
|
|
|
return {
|
|
|
|
|
"status": "no_linked_customer",
|
|
|
|
|
"message": "Kontakt er ikke tilknyttet et firma",
|
|
|
|
|
"recurring_orders": [],
|
|
|
|
|
"sales_orders": [],
|
|
|
|
|
"subscriptions": [],
|
|
|
|
|
"expired_subscriptions": [],
|
|
|
|
|
"bmc_office_subscriptions": [],
|
|
|
|
|
}
|
|
|
|
|
return await get_customer_subscriptions(customer_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/{contact_id}/subscriptions/lock")
|
|
|
|
|
async def lock_contact_subscriptions(contact_id: int, lock_request: dict):
|
|
|
|
|
customer_id = get_primary_customer_id(contact_id)
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
|
|
|
|
|
return await lock_customer_subscriptions(customer_id, lock_request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/contacts/{contact_id}/subscription-comment")
|
|
|
|
|
async def save_contact_subscription_comment(contact_id: int, data: SubscriptionComment):
|
|
|
|
|
customer_id = get_primary_customer_id(contact_id)
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
|
|
|
|
|
return await save_subscription_comment(customer_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/subscription-comment")
|
|
|
|
|
async def get_contact_subscription_comment(contact_id: int):
|
|
|
|
|
customer_id = get_primary_customer_id(contact_id)
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
|
|
|
|
|
return await get_subscription_comment(customer_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/subscriptions/billing-matrix")
|
|
|
|
|
async def get_contact_subscription_billing_matrix(
|
|
|
|
|
contact_id: int,
|
|
|
|
|
months: int = Query(default=12, ge=1, le=60, description="Number of months to show"),
|
|
|
|
|
):
|
|
|
|
|
customer_id = get_primary_customer_id(contact_id)
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
|
|
|
|
|
return await get_subscription_billing_matrix(customer_id, months)
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/kontakt")
|
|
|
|
|
async def get_contact_kontakt_history(contact_id: int, limit: int = Query(default=200, ge=1, le=1000)):
|
|
|
|
|
try:
|
|
|
|
|
exists = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
if not exists:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM (
|
|
|
|
|
SELECT
|
|
|
|
|
'call' AS type,
|
|
|
|
|
t.id::text AS event_id,
|
|
|
|
|
t.started_at AS happened_at,
|
|
|
|
|
t.direction,
|
|
|
|
|
t.ekstern_nummer AS number,
|
|
|
|
|
NULL::text AS message,
|
|
|
|
|
t.duration_sec,
|
|
|
|
|
COALESCE(u.full_name, u.username) AS user_name,
|
|
|
|
|
NULL::text AS sms_status
|
|
|
|
|
FROM telefoni_opkald t
|
|
|
|
|
LEFT JOIN users u ON u.user_id = t.bruger_id
|
|
|
|
|
WHERE t.kontakt_id = %s
|
|
|
|
|
|
|
|
|
|
UNION ALL
|
|
|
|
|
|
|
|
|
|
SELECT
|
|
|
|
|
'sms' AS type,
|
|
|
|
|
s.id::text AS event_id,
|
|
|
|
|
s.created_at AS happened_at,
|
|
|
|
|
NULL::text AS direction,
|
|
|
|
|
s.recipient AS number,
|
|
|
|
|
s.message,
|
|
|
|
|
NULL::int AS duration_sec,
|
|
|
|
|
COALESCE(u.full_name, u.username) AS user_name,
|
|
|
|
|
s.status AS sms_status
|
|
|
|
|
FROM sms_messages s
|
|
|
|
|
LEFT JOIN users u ON u.user_id = s.bruger_id
|
|
|
|
|
WHERE s.kontakt_id = %s
|
2026-08-17 19:27:43 +02:00
|
|
|
|
|
|
|
|
UNION ALL
|
|
|
|
|
|
|
|
|
|
SELECT
|
|
|
|
|
'merge' AS type,
|
|
|
|
|
h.id::text AS event_id,
|
|
|
|
|
h.merged_at AS happened_at,
|
|
|
|
|
NULL::text AS direction,
|
|
|
|
|
NULL::text AS number,
|
|
|
|
|
CONCAT(
|
|
|
|
|
'Flettet med ',
|
|
|
|
|
COALESCE(NULLIF(TRIM(CONCAT(
|
|
|
|
|
h.source_snapshot->>'first_name', ' ',
|
|
|
|
|
h.source_snapshot->>'last_name'
|
|
|
|
|
)), ''), 'kontakt #' || h.source_contact_id::text),
|
|
|
|
|
' (#', h.source_contact_id::text, ')'
|
|
|
|
|
) AS message,
|
|
|
|
|
NULL::int AS duration_sec,
|
|
|
|
|
NULL::text AS user_name,
|
|
|
|
|
'completed'::text AS sms_status
|
|
|
|
|
FROM contact_merge_history h
|
|
|
|
|
WHERE h.target_contact_id = %s
|
2026-02-14 02:26:29 +01:00
|
|
|
) z
|
|
|
|
|
ORDER BY z.happened_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
rows = execute_query(query, (contact_id, contact_id, contact_id, limit)) or []
|
2026-02-14 02:26:29 +01:00
|
|
|
return {"items": rows}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to fetch kontakt history for contact {contact_id}: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|