""" Contact API Router - Simplified (Read-Only) Only GET endpoints for now """ from fastapi import APIRouter, HTTPException, Query, Body, status from typing import 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.customers.backend.router import ( get_customer_subscriptions, lock_customer_subscriptions, save_subscription_comment, get_subscription_comment, get_subscription_billing_matrix, SubscriptionComment, ) import logging import json 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) 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"), ) 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 = [] if search: where_clauses.append( """ ( c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s OR c.phone ILIKE %s OR c.mobile ILIKE %s OR 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 ) ) """ ) like = f"%{search}%" params.extend([like, like, like, like, like, like, like]) 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) 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 c.last_name, c.first_name, c.id LIMIT %s OFFSET %s """ contacts_params = list(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))