bmc_hub/app/contacts/backend/router_simple.py

1424 lines
57 KiB
Python
Raw Normal View History

"""
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
from pydantic import BaseModel, Field
from app.core.database import (
execute_query,
execute_insert,
execute_query_single,
get_db_connection,
release_db_connection,
)
from psycopg2.extras import RealDictCursor
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
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,
)
import logging
import json
2026-08-28 20:49:55 +02:00
import re
import html
from email.utils import parseaddr
logger = logging.getLogger(__name__)
router = APIRouter()
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
title: Optional[str] = None
2026-07-10 08:10:31 +02:00
department: Optional[str] = None
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
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
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
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}
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
role: Optional[str] = None
@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))
@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)
if is_active is not None:
where_clauses.append("c.is_active = %s")
params.append(is_active)
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)
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 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}"
count_result = execute_query(count_query, tuple(params))
total = count_result[0]['count'] if count_result else 0
# 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}%"]
contacts_query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
FROM contacts c
{where_sql}
2026-08-28 20:49:55 +02:00
ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
contacts_params = list(params)
2026-08-28 20:49:55 +02:00
contacts_params.extend(rank_params)
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 []
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))
@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)
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-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))
# Link to company if provided
2026-07-10 08:10:31 +02:00
for idx, company_id in enumerate(company_ids):
try:
link_query = """
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, true, 'primary')
ON CONFLICT (contact_id, customer_id)
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
RETURNING id
"""
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),
)
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}")
# 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))
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)
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies"""
try:
# Get contact info
query = """
SELECT
id, first_name, last_name, email, phone, mobile,
title, department, is_active, user_company, vtiger_id,
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")
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
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))
@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))
@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))
@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))
@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))
@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))
@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)
@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
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
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (contact_id, contact_id, contact_id, limit)) or []
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))