""" Contact API Router - Simplified (Read-Only) Only GET endpoints for now """ 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 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, CustomerCreate, create_customer, ) import logging import json 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 mobile: Optional[str] = None title: Optional[str] = None department: Optional[str] = None company_id: Optional[int] = None 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) 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"), ) 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).*?>.*?", " ", raw_html) raw_html = re.sub(r"(?i)|

|", "\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 = [] 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 ) """ 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) 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} ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id LIMIT %s OFFSET %s """ contacts_params = list(params) 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 = """ 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, ( contact.first_name, contact.last_name, contact.email, contact.phone, contact.mobile, contact.title, contact.department, contact.is_active, ) ) 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 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 """ 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: 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))