import os import logging import aiohttp import pytz import base64 import asyncio from fastapi import APIRouter, HTTPException, Header, Body from pydantic import BaseModel from dateutil import parser from datetime import datetime from typing import Optional logger = logging.getLogger("uvicorn.error") router = APIRouter() # 🔧 Supabase Config SUPABASE_URL = "https://ussxqnifefkgkaumjann.supabase.co" SUPABASE_KEY = os.getenv("SUPA_KEY") SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY") if not SUPABASE_KEY or not SUPABASE_ROLE_KEY: raise ValueError("❌ SUPA_KEY or SUPA_SERVICE_KEY not set in environment!") SUPABASE_HEADERS = { "apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json" } SUPABASE_ROLE_HEADERS = { "apikey": SUPABASE_ROLE_KEY, "Authorization": f"Bearer {SUPABASE_ROLE_KEY}", "Content-Type": "application/json", "Prefer": "return=representation" } # 🛡️ Verificação de token de usuário (sem admin check) async def verify_user_token(user_token: str) -> str: headers = { "Authorization": f"Bearer {user_token}", "apikey": SUPABASE_KEY, "Content-Type": "application/json" } async with aiohttp.ClientSession() as session: async with session.get(f"{SUPABASE_URL}/auth/v1/user", headers=headers) as response: if response.status != 200: raise HTTPException(status_code=401, detail="Token inválido ou expirado") user_data = await response.json() user_id = user_data.get("id") if not user_id: raise HTTPException(status_code=400, detail="ID do usuário não encontrado") return user_id # 📨 Modelo da requisição de ticket class CreateTicketRequest(BaseModel): message: str subject: str priority: str def format_datetime(dt_str): dt = parser.isoparse(dt_str) return dt.strftime("%m/%d/%Y, %I:%M %p") # Hora com AM/PM @router.get("/ticket/user") async def get_user_tickets( user_token: str = Header(None, alias="User-key") ): user_id = await verify_user_token(user_token) async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/Tickets?user_id=eq.{user_id}&order=created_at.desc&limit=50", headers=SUPABASE_ROLE_HEADERS ) as ticket_resp: if ticket_resp.status != 200: error_detail = await ticket_resp.text() raise HTTPException(status_code=500, detail=f"Error fetching tickets: {error_detail}") tickets = await ticket_resp.json() ticket_results = [] for ticket in tickets: ticket_id = ticket["id"] ticket_created = parser.isoparse(ticket["created_at"]) formatted_ticket_date = ticket_created.strftime("%m/%d/%Y") # Buscar a primeira mensagem async with session.get( f"{SUPABASE_URL}/rest/v1/messages_tickets?ticket_id=eq.{ticket_id}&order=created_at.asc&limit=1", headers=SUPABASE_ROLE_HEADERS ) as first_msg_resp: first_message = None if first_msg_resp.status == 200: messages = await first_msg_resp.json() first_message = messages[0] if messages else None # Buscar todas mensagens para histórico e status async with session.get( f"{SUPABASE_URL}/rest/v1/messages_tickets?ticket_id=eq.{ticket_id}&order=created_at.asc", headers=SUPABASE_ROLE_HEADERS ) as all_msg_resp: all_messages = await all_msg_resp.json() if all_msg_resp.status == 200 else [] history = [f"Ticket created on {ticket_created.strftime('%m/%d/%Y, %I:%M %p')}"] # Detectar mudanças de suporte assigned_support_ids = [] last_support_id = None for msg in all_messages: msg_user = msg["user"] if msg_user != user_id and msg_user != last_support_id: last_support_id = msg_user assigned_support_ids.append((msg_user, msg["created_at"])) for support_id, assigned_time in assigned_support_ids: async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{support_id}", headers=SUPABASE_ROLE_HEADERS ) as user_resp: support_name = "Support" if user_resp.status == 200: user_data = await user_resp.json() if user_data: support_name = user_data[0].get("name", "Support") history.append(f"Assigned to {support_name} on {format_datetime(assigned_time)}") # Status e última atualização (se necessário) status = "Open" if ticket.get("finished"): status = "Closed" if ticket.get("finished_date"): closed_dt = parser.isoparse(ticket["finished_date"]) history.append(f"Ticket closed on {closed_dt.strftime('%m/%d/%Y, %I:%M %p')}") elif all_messages: last_msg = all_messages[-1] if last_msg["user"] != user_id: status = "Answered" else: status = "Open" # já está por padrão, mas pode ser útil explicitar if len(all_messages) > 1: # Sempre adicionar o last updated se houver mais de uma mensagem last_msg_time = format_datetime(last_msg["created_at"]) history.append(f"Last updated on {last_msg_time}") # Montar retorno do ticket ticket_data = dict(ticket) ticket_data["formatted_date"] = formatted_ticket_date ticket_data["status"] = status if ticket.get("support_id"): async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{ticket['support_id']}", headers=SUPABASE_ROLE_HEADERS ) as support_resp: if support_resp.status == 200: support_data = await support_resp.json() if support_data: support_info = support_data[0] ticket_data["support_name"] = support_info.get("name") ticket_data["support_email"] = support_info.get("email") ticket_results.append({ "ticket": ticket_data, "first_message": first_message, "history": history }) return ticket_results @router.get("/ticket/detail") async def get_ticket_details(ticket_id: int): async with aiohttp.ClientSession() as session: # 1. Buscar dados do ticket async with session.get( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{ticket_id}", headers=SUPABASE_ROLE_HEADERS ) as ticket_resp: if ticket_resp.status != 200: raise HTTPException(status_code=404, detail="Ticket não encontrado") ticket_data = await ticket_resp.json() if not ticket_data: raise HTTPException(status_code=404, detail="Ticket inexistente") ticket = ticket_data[0] # 2. Buscar as 50 últimas mensagens async with session.get( f"{SUPABASE_URL}/rest/v1/messages_tickets?ticket_id=eq.{ticket_id}&order=created_at.desc&limit=50", headers=SUPABASE_ROLE_HEADERS ) as msg_resp: if msg_resp.status != 200: raise HTTPException(status_code=500, detail="Erro ao buscar mensagens") messages_raw = await msg_resp.json() # 3. Buscar info dos usuários das mensagens user_cache = {} for msg in messages_raw: user_id = msg["user"] if user_id not in user_cache: async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}", headers=SUPABASE_ROLE_HEADERS ) as user_resp: if user_resp.status == 200: user_data = await user_resp.json() if user_data: u = user_data[0] user_cache[user_id] = { "id": u["id"], "name": u.get("name", "Desconhecido"), "avatar": u.get("avatar", None), "type": "support" if u.get("role") == "admin" else "customer" } else: user_cache[user_id] = { "id": user_id, "name": "Desconhecido", "avatar": None, "type": "unknown" } # 4. Substituir o campo user nas mensagens messages = [] for msg in messages_raw: uid = msg["user"] user_info = user_cache.get(uid, { "id": uid, "name": "Desconhecido", "avatar": None, }) # Definir o tipo com base no ticket if uid == ticket["user_id"]: user_info["type"] = "customer" else: user_info["type"] = "support" messages.append({ **msg, "user": user_info }) return { "ticket": ticket, "messages": messages } class RespondTicketRequest(BaseModel): ticket_id: int content: str @router.post("/ticket/respond") async def respond_ticket( body: RespondTicketRequest, user_token: str = Header(None, alias="User-key") ): user_id = await verify_user_token(user_token) created_at = datetime.utcnow().isoformat() # 1. Buscar dados do ticket async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{body.ticket_id}", headers=SUPABASE_ROLE_HEADERS ) as ticket_resp: if ticket_resp.status != 200: raise HTTPException(status_code=404, detail="Ticket não encontrado") ticket_data = await ticket_resp.json() if not ticket_data: raise HTTPException(status_code=404, detail="Ticket inexistente") ticket = ticket_data[0] support_id = ticket.get("support_id") ticket_creator_id = ticket.get("user_id") # <- CORRIGIDO AQUI # 2. Verificar/Atribuir suporte if support_id is None: # Verifica se usuário é admin async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}", headers=SUPABASE_ROLE_HEADERS ) as user_resp: if user_resp.status != 200: raise HTTPException(status_code=403, detail="Usuário inválido") user_data = await user_resp.json() if not user_data or not user_data[0].get("is_admin", False): raise HTTPException(status_code=403, detail="Apenas administradores podem assumir tickets") # Atualiza support_id async with aiohttp.ClientSession() as session: async with session.patch( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{body.ticket_id}", headers={**SUPABASE_ROLE_HEADERS, "Prefer": "return=representation"}, json={"support_id": user_id} ) as patch_resp: if patch_resp.status != 200: raise HTTPException(status_code=500, detail="Erro ao atribuir o suporte ao ticket") else: # Permitir exceção se o usuário for o criador do ticket if support_id != user_id and user_id != ticket_creator_id: raise HTTPException(status_code=403, detail="Ticket já está atribuído a outro suporte") # 3. Espera 1 segundo antes de enviar a mensagem await asyncio.sleep(1) # 3.1. Reabrir ticket se estiver fechado if ticket.get("finished", False) is True: async with aiohttp.ClientSession() as session: async with session.patch( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{body.ticket_id}", headers={**SUPABASE_ROLE_HEADERS, "Prefer": "return=representation"}, json={"finished": False, "finished_date": None} ) as reopen_resp: if reopen_resp.status != 200: raise HTTPException(status_code=500, detail="Erro ao reabrir o ticket") # 4. Criar mensagem message_payload = { "user": user_id, "content": body.content, "created_at": created_at, "ticket_id": body.ticket_id } async with aiohttp.ClientSession() as session: async with session.post( f"{SUPABASE_URL}/rest/v1/messages_tickets", headers=SUPABASE_ROLE_HEADERS, json=message_payload ) as message_resp: if message_resp.status != 201: error_detail = await message_resp.text() raise HTTPException(status_code=500, detail=f"Erro ao salvar a mensagem: {error_detail}") message_data = await message_resp.json() return { "status": "response sent successfully", "ticket_id": body.ticket_id, "message_id": message_data[0]["id"], "message_content": body.content } @router.post("/ticket/create") async def create_ticket( body: CreateTicketRequest, user_token: str = Header(None, alias="User-key") ): user_id = await verify_user_token(user_token) created_at = datetime.utcnow().isoformat() ticket_payload = { "user_id": user_id, "support_id": None, "created_at": created_at, "subject": body.subject, "priority": body.priority } async with aiohttp.ClientSession() as session: async with session.post( f"{SUPABASE_URL}/rest/v1/Tickets", headers=SUPABASE_ROLE_HEADERS, json=ticket_payload ) as ticket_resp: if ticket_resp.status != 201: error_detail = await ticket_resp.text() raise HTTPException(status_code=500, detail=f"Erro ao criar ticket: {error_detail}") ticket_data = await ticket_resp.json() ticket_id = ticket_data[0]["id"] message_payload = { "user": user_id, "content": body.message, "created_at": created_at, "ticket_id": ticket_id } async with aiohttp.ClientSession() as session: async with session.post( f"{SUPABASE_URL}/rest/v1/messages_tickets", headers=SUPABASE_ROLE_HEADERS, json=message_payload ) as message_resp: if message_resp.status != 201: error_detail = await message_resp.text() raise HTTPException(status_code=500, detail=f"Erro ao criar mensagem: {error_detail}") return {"ticket_id": ticket_id} class CloseTicketRequest(BaseModel): ticket_id: int @router.post("/ticket/close") async def close_ticket( body: CloseTicketRequest, user_token: str = Header(None, alias="User-key") ): user_id = await verify_user_token(user_token) logger.info(f"User ID verified: {user_id}") finished_date = datetime.utcnow().isoformat() # Verificar se o ticket já está finalizado async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{body.ticket_id}", headers=SUPABASE_ROLE_HEADERS ) as get_resp: if get_resp.status != 200: error_detail = await get_resp.text() logger.error(f"Error fetching ticket: {error_detail}") raise HTTPException(status_code=500, detail=f"Error fetching ticket: {error_detail}") ticket_data = await get_resp.json() if not ticket_data: raise HTTPException(status_code=404, detail="Ticket not found") ticket = ticket_data[0] if ticket.get("finished", False): logger.info(f"Ticket {body.ticket_id} is already closed.") return {"message": "Ticket is already closed", "ticket_id": body.ticket_id} # Atualizar o finished e finished_date update_payload = { "finished": True, "finished_date": finished_date } logger.info(f"Updating ticket with payload: {update_payload}") try: async with aiohttp.ClientSession() as session: async with session.patch( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{body.ticket_id}", headers={ **SUPABASE_ROLE_HEADERS, "Content-Type": "application/json" }, json=update_payload ) as update_resp: logger.info(f"Response status: {update_resp.status}") response_data = await update_resp.json() # Alterado de text() para json(), pois é uma resposta JSON. logger.info(f"Response data: {response_data}") if update_resp.status == 200: # Verificar se o ticket foi realmente atualizado corretamente. updated_ticket = response_data[0] if updated_ticket["finished"] and updated_ticket["finished_date"]: logger.info(f"Ticket {body.ticket_id} closed successfully.") return {"message": "Ticket closed successfully", "ticket_id": body.ticket_id} else: logger.error(f"Ticket update failed: {updated_ticket}") raise HTTPException(status_code=500, detail="Ticket update failed.") else: logger.error(f"Error updating ticket: {response_data}") raise HTTPException(status_code=500, detail=f"Error updating ticket: {response_data}") except aiohttp.ClientError as e: logger.error(f"AIOHTTP client error: {str(e)}") raise HTTPException(status_code=500, detail=f"Client error: {str(e)}") except Exception as e: logger.error(f"Unexpected error: {str(e)}") raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}") # 📧 Envio de e-mails com Gmail API GMAIL_CLIENT_ID = "784687789817-3genmmvps11ip3a6fkbkkd8dm3bstgdc.apps.googleusercontent.com" GMAIL_CLIENT_SECRET = "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" GMAIL_REFRESH_TOKEN = "1//04ZOO_chVwlYiCgYIARAAGAQSNwF-L9IrhQO1ij79thk-DTjiMudl_XQshuU5CDTDYtt8rrOTMbz_rL8ECGjNfEN9da6W-mnjhZA" class TicketResponseRequest(BaseModel): ticket_id: int content: str async def get_gmail_access_token() -> str: url = "https://oauth2.googleapis.com/token" data = { "client_id": GMAIL_CLIENT_ID, "client_secret": GMAIL_CLIENT_SECRET, "refresh_token": GMAIL_REFRESH_TOKEN, "grant_type": "refresh_token" } async with aiohttp.ClientSession() as session: async with session.post(url, data=data) as response: if response.status != 200: raise HTTPException(status_code=500, detail="Erro ao obter access_token do Gmail") token_data = await response.json() return token_data["access_token"] def encode_message(raw_message: str) -> str: return base64.urlsafe_b64encode(raw_message.encode("utf-8")).decode("utf-8").replace("=", "") @router.post("/ticket/support/respond") async def respond_ticket( payload: TicketResponseRequest, user_token: str = Header(None, alias="User-key") ): # 1. Verify support agent token support_id = await verify_user_token(user_token) created_at = datetime.utcnow().isoformat() # Get support agent name async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{support_id}", headers=SUPABASE_ROLE_HEADERS ) as support_user_resp: if support_user_resp.status != 200: raise HTTPException(status_code=404, detail="Support agent not found") support_user_data = await support_user_resp.json() if not support_user_data: raise HTTPException(status_code=404, detail="Support agent does not exist") support_name = support_user_data[0].get("name", "Support Team").strip() # 2. Retrieve ticket async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/Tickets?id=eq.{payload.ticket_id}", headers=SUPABASE_ROLE_HEADERS ) as ticket_resp: if ticket_resp.status != 200: raise HTTPException(status_code=404, detail="Ticket not found") ticket_data = await ticket_resp.json() if not ticket_data: raise HTTPException(status_code=404, detail="Ticket does not exist") ticket = ticket_data[0] user_id = ticket["user_id"] # 3. Get user email and name async with aiohttp.ClientSession() as session: async with session.get( f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}", headers=SUPABASE_ROLE_HEADERS ) as user_resp: if user_resp.status != 200: raise HTTPException(status_code=404, detail="User not found") user_data = await user_resp.json() if not user_data: raise HTTPException(status_code=404, detail="User does not exist") user_email = user_data[0]["email"] user_name = user_data[0].get("name", "user").strip() first_name = user_name.split(" ")[0] if user_name else "user" # 4. Send email with clean, elegant HTML template with brand purple gradient access_token = await get_gmail_access_token() subject = f"Customer Support - Case {payload.ticket_id}" email_html = f"""

Hello {first_name},

{payload.content}

Best regards,

{support_name}

Customer Support

ClosetCoach © 2025
""" raw_message = f"""To: {user_email} From: "ClosetCoach" Subject: {subject} Content-Type: text/html; charset="UTF-8" {email_html} """ encoded_message = encode_message(raw_message) async with aiohttp.ClientSession() as session: async with session.post( "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", headers={ "Authorization": f"Bearer {access_token}", "Content-Type": "application/json" }, json={"raw": encoded_message} ) as gmail_resp: if gmail_resp.status != 200: error_detail = await gmail_resp.text() raise HTTPException(status_code=500, detail=f"Error sending email: {error_detail}") gmail_data = await gmail_resp.json() # 5. Save response in messages_tickets message_payload = { "user": support_id, "content": payload.content, "created_at": created_at, "ticket_id": payload.ticket_id } async with aiohttp.ClientSession() as session: async with session.post( f"{SUPABASE_URL}/rest/v1/messages_tickets", headers=SUPABASE_ROLE_HEADERS, json=message_payload ) as msg_resp: if msg_resp.status != 201: error_detail = await msg_resp.text() raise HTTPException(status_code=500, detail=f"Error recording response: {error_detail}") return { "status": "response sent successfully", "ticket_id": payload.ticket_id, "email_to": user_email, "message_id": gmail_data.get("id") }