2026-01-11 19:23:21 +01:00
|
|
|
"""
|
|
|
|
|
Conversations Router
|
|
|
|
|
Handles audio conversations, transcriptions, and privacy settings.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Depends, Query, status
|
|
|
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from app.core.database import execute_query, execute_update
|
|
|
|
|
from app.models.schemas import Conversation, ConversationUpdate
|
|
|
|
|
from app.core.config import settings
|
2026-02-11 23:51:21 +01:00
|
|
|
from app.core.contact_utils import get_contact_customer_ids
|
2026-01-11 19:23:21 +01:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
@router.get("/conversations", response_model=List[Conversation])
|
|
|
|
|
async def get_conversations(
|
|
|
|
|
request: Request,
|
|
|
|
|
customer_id: Optional[int] = None,
|
2026-02-11 23:51:21 +01:00
|
|
|
contact_id: Optional[int] = None,
|
2026-01-11 19:23:21 +01:00
|
|
|
ticket_id: Optional[int] = None,
|
|
|
|
|
only_mine: bool = False,
|
|
|
|
|
include_deleted: bool = False
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
List conversations with filtering.
|
|
|
|
|
"""
|
|
|
|
|
where_clauses = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
# Default: Exclude deleted
|
|
|
|
|
if not include_deleted:
|
|
|
|
|
where_clauses.append("deleted_at IS NULL")
|
|
|
|
|
|
2026-02-11 23:51:21 +01:00
|
|
|
if contact_id:
|
|
|
|
|
contact_customer_ids = get_contact_customer_ids(contact_id)
|
|
|
|
|
if customer_id is not None:
|
|
|
|
|
if customer_id not in contact_customer_ids:
|
|
|
|
|
return []
|
|
|
|
|
contact_customer_ids = [customer_id]
|
|
|
|
|
|
|
|
|
|
if not contact_customer_ids:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
placeholders = ",".join(["%s"] * len(contact_customer_ids))
|
|
|
|
|
where_clauses.append(f"customer_id IN ({placeholders})")
|
|
|
|
|
params.extend(contact_customer_ids)
|
|
|
|
|
elif customer_id:
|
2026-01-11 19:23:21 +01:00
|
|
|
where_clauses.append("customer_id = %s")
|
|
|
|
|
params.append(customer_id)
|
|
|
|
|
|
|
|
|
|
if ticket_id:
|
|
|
|
|
where_clauses.append("ticket_id = %s")
|
|
|
|
|
params.append(ticket_id)
|
|
|
|
|
|
|
|
|
|
# Filtering Logic for Privacy
|
|
|
|
|
# 1. Technical implementation of 'only_mine' depends on auth user context
|
|
|
|
|
# Assuming we might have a user_id in session or request state (not fully clear from context, defaulting to param)
|
|
|
|
|
# For this implementation, I'll assume 'only_mine' filters by the current user if available, or just ignored if no auth.
|
|
|
|
|
|
|
|
|
|
# Note: Access Control logic should be here.
|
|
|
|
|
# For now, we return public conversations OR private ones owned by user.
|
|
|
|
|
# Since auth is light in this project, we implement basic logic.
|
|
|
|
|
|
|
|
|
|
auth_user_id = None # data.get('user_id') # To be implemented with auth middleware
|
|
|
|
|
# Taking a pragmatic approach: if is_private is true, we ideally shouldn't return it unless authorized.
|
|
|
|
|
# For now, we return all, assuming the frontend filters or backend auth is added later.
|
|
|
|
|
|
|
|
|
|
if only_mine and auth_user_id:
|
|
|
|
|
where_clauses.append("user_id = %s")
|
|
|
|
|
params.append(auth_user_id)
|
|
|
|
|
|
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
|
|
|
|
|
|
|
|
|
|
query = f"""
|
|
|
|
|
SELECT * FROM conversations
|
|
|
|
|
WHERE {where_sql}
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
results = execute_query(query, tuple(params))
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
@router.get("/conversations/{conversation_id}/audio")
|
|
|
|
|
async def get_conversation_audio(conversation_id: int):
|
|
|
|
|
"""
|
|
|
|
|
Stream the audio file for a conversation.
|
|
|
|
|
"""
|
|
|
|
|
query = "SELECT audio_file_path FROM conversations WHERE id = %s"
|
|
|
|
|
results = execute_query(query, (conversation_id,))
|
|
|
|
|
|
|
|
|
|
if not results:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
|
|
|
|
|
|
|
|
# Security check: Check if deleted
|
|
|
|
|
record = results[0]
|
|
|
|
|
# (If using soft delete, check deleted_at if not admin)
|
|
|
|
|
|
|
|
|
|
file_path_str = record['audio_file_path']
|
|
|
|
|
file_path = Path(file_path_str)
|
|
|
|
|
|
|
|
|
|
# Validation
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
# Fallback to absolute path check if stored relative
|
|
|
|
|
abs_path = Path(os.getcwd()) / file_path
|
|
|
|
|
if not abs_path.exists():
|
|
|
|
|
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
|
|
|
|
file_path = abs_path
|
|
|
|
|
|
|
|
|
|
return FileResponse(file_path, media_type="audio/mpeg", filename=file_path.name)
|
|
|
|
|
|
|
|
|
|
@router.delete("/conversations/{conversation_id}")
|
|
|
|
|
async def delete_conversation(conversation_id: int, hard_delete: bool = False):
|
|
|
|
|
"""
|
|
|
|
|
Delete a conversation.
|
|
|
|
|
hard_delete=True removes file and record permanently (GDPR).
|
|
|
|
|
hard_delete=False sets deleted_at (Recycle Bin).
|
|
|
|
|
"""
|
|
|
|
|
# 1. Fetch info
|
|
|
|
|
query = "SELECT * FROM conversations WHERE id = %s"
|
|
|
|
|
results = execute_query(query, (conversation_id,))
|
|
|
|
|
if not results:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
|
|
|
|
|
|
|
|
conv = results[0]
|
|
|
|
|
|
|
|
|
|
if hard_delete:
|
|
|
|
|
# HARD DELETE
|
|
|
|
|
# 1. Delete file
|
|
|
|
|
try:
|
|
|
|
|
file_path = Path(conv['audio_file_path'])
|
|
|
|
|
if not file_path.is_absolute():
|
|
|
|
|
file_path = Path(os.getcwd()) / file_path
|
|
|
|
|
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
os.remove(file_path)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# Log error but continue to cleanup DB? Or fail?
|
|
|
|
|
# Better to fail safely or ensure DB matches reality
|
|
|
|
|
print(f"Error deleting file: {e}")
|
|
|
|
|
|
|
|
|
|
# 2. Delete DB Record
|
|
|
|
|
execute_update("DELETE FROM conversations WHERE id = %s", (conversation_id,))
|
|
|
|
|
return {"status": "permanently_deleted"}
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# SOFT DELETE
|
|
|
|
|
execute_update(
|
|
|
|
|
"UPDATE conversations SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s",
|
|
|
|
|
(conversation_id,)
|
|
|
|
|
)
|
|
|
|
|
return {"status": "moved_to_trash"}
|
|
|
|
|
|
|
|
|
|
@router.patch("/conversations/{conversation_id}", response_model=Conversation)
|
|
|
|
|
async def update_conversation(conversation_id: int, update: ConversationUpdate):
|
|
|
|
|
"""
|
|
|
|
|
Update conversation metadata (privacy, title, links).
|
|
|
|
|
"""
|
|
|
|
|
# Build update query dynamically
|
|
|
|
|
fields = []
|
|
|
|
|
values = []
|
|
|
|
|
|
|
|
|
|
if update.title is not None:
|
|
|
|
|
fields.append("title = %s")
|
|
|
|
|
values.append(update.title)
|
|
|
|
|
|
|
|
|
|
if update.is_private is not None:
|
|
|
|
|
fields.append("is_private = %s")
|
|
|
|
|
values.append(update.is_private)
|
|
|
|
|
|
|
|
|
|
if update.ticket_id is not None:
|
|
|
|
|
fields.append("ticket_id = %s")
|
|
|
|
|
values.append(update.ticket_id)
|
|
|
|
|
|
|
|
|
|
if update.customer_id is not None:
|
|
|
|
|
fields.append("customer_id = %s")
|
|
|
|
|
values.append(update.customer_id)
|
|
|
|
|
|
|
|
|
|
if update.category is not None:
|
|
|
|
|
fields.append("category = %s")
|
|
|
|
|
values.append(update.category)
|
|
|
|
|
|
|
|
|
|
if not fields:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
|
|
|
|
|
|
fields.append("updated_at = CURRENT_TIMESTAMP")
|
|
|
|
|
|
|
|
|
|
values.append(conversation_id)
|
|
|
|
|
query = f"UPDATE conversations SET {', '.join(fields)} WHERE id = %s RETURNING *"
|
|
|
|
|
|
|
|
|
|
# execute_query often returns list of dicts for SELECT/RETURNING
|
|
|
|
|
results = execute_query(query, tuple(values))
|
|
|
|
|
|
|
|
|
|
if not results:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
|
|
|
|
|
|
|
|
return results[0]
|