bmc_hub/app/contacts/backend/router_simple.py
Christian 0831715d3a feat: add SMS service and frontend integration
- Implement SmsService class for sending SMS via CPSMS API.
- Add SMS sending functionality in the frontend with validation and user feedback.
- Create database migrations for SMS message storage and telephony features.
- Introduce telephony settings and user-specific configurations for click-to-call functionality.
- Enhance user experience with toast notifications for incoming calls and actions.
2026-02-14 02:26:29 +01:00

461 lines
17 KiB
Python

"""
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
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
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
title: Optional[str] = None
company_id: Optional[int] = None
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 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)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if is_active is not None:
where_clauses.append("c.is_active = %s")
params.append(is_active)
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total (needs alias c for consistency)
count_query = f"SELECT COUNT(*) 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
# Get contacts with company info
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,
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
{where_sql}
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 company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
contacts = execute_query(query, tuple(params))
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, title, is_active)
VALUES (%s, %s, %s, %s, %s, true)
RETURNING id
"""
contact_id = execute_insert(
insert_query,
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title)
)
# Link to company if provided
if contact.company_id:
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, contact.company_id))
except Exception as e:
logger.error(f"Failed to link new contact {contact_id} to company {contact.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))
@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.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}/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
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (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))