Update routes/subscription.py
Browse files- routes/subscription.py +152 -4
routes/subscription.py
CHANGED
|
@@ -8,8 +8,11 @@ import os
|
|
| 8 |
import requests
|
| 9 |
import asyncio
|
| 10 |
import jwt
|
|
|
|
| 11 |
from fastapi import APIRouter, HTTPException, Request, Header
|
| 12 |
from pydantic import BaseModel
|
|
|
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
|
|
@@ -25,6 +28,10 @@ SUPABASE_URL = "https://ussxqnifefkgkaumjann.supabase.co"
|
|
| 25 |
SUPABASE_KEY = os.getenv("SUPA_KEY") # Lendo do ambiente
|
| 26 |
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY")
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
if not stripe.api_key or not SUPABASE_KEY or not SUPABASE_ROLE_KEY:
|
| 29 |
raise ValueError("❌ STRIPE_KEY, SUPA_KEY ou SUPA_SERVICE_KEY não foram definidos no ambiente!")
|
| 30 |
|
|
@@ -61,6 +68,133 @@ class CreatePriceRequest(BaseModel):
|
|
| 61 |
emergency_price: int # Valor de emergência (ex: 500 para R$5,00)
|
| 62 |
consultations: int # Número de consultas (ex: 3)
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def get_active_subscribers_by_price_id(price_id: str) -> list:
|
| 65 |
"""
|
| 66 |
Retorna uma lista de customer_ids que têm uma assinatura ativa com o price_id fornecido.
|
|
@@ -977,17 +1111,31 @@ async def create_price(data: CreatePriceRequest, user_token: str = Header(None,
|
|
| 977 |
if update_response.status_code not in [200, 204]:
|
| 978 |
raise HTTPException(status_code=500, detail=f"Failed to update user: {update_response.text}")
|
| 979 |
|
| 980 |
-
# 🔥 Cria notificações para os afetados
|
| 981 |
create_notifications_for_price_change(affected_users, stylist_id=user_id)
|
| 982 |
|
| 983 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 984 |
|
| 985 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 986 |
|
| 987 |
except Exception as e:
|
| 988 |
logger.error(f"❌ Error creating price: {e}")
|
| 989 |
raise HTTPException(status_code=500, detail=f"Error creating price: {str(e)}")
|
| 990 |
-
|
| 991 |
@router.post("/emergency_checkout_session")
|
| 992 |
def emergency_checkout_session(
|
| 993 |
data: EmergencyPaymentRequest,
|
|
|
|
| 8 |
import requests
|
| 9 |
import asyncio
|
| 10 |
import jwt
|
| 11 |
+
import hashlib
|
| 12 |
from fastapi import APIRouter, HTTPException, Request, Header
|
| 13 |
from pydantic import BaseModel
|
| 14 |
+
from google.oauth2 import service_account
|
| 15 |
+
from google.auth.transport.requests import Request as GoogleRequest
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
|
|
|
| 28 |
SUPABASE_KEY = os.getenv("SUPA_KEY") # Lendo do ambiente
|
| 29 |
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY")
|
| 30 |
|
| 31 |
+
# Firebase config para notificações push
|
| 32 |
+
SERVICE_ACCOUNT_FILE = './closetcoach-2d50b-firebase-adminsdk-fbsvc-7fcccbacb1.json'
|
| 33 |
+
FCM_PROJECT_ID = "closetcoach-2d50b"
|
| 34 |
+
|
| 35 |
if not stripe.api_key or not SUPABASE_KEY or not SUPABASE_ROLE_KEY:
|
| 36 |
raise ValueError("❌ STRIPE_KEY, SUPA_KEY ou SUPA_SERVICE_KEY não foram definidos no ambiente!")
|
| 37 |
|
|
|
|
| 68 |
emergency_price: int # Valor de emergência (ex: 500 para R$5,00)
|
| 69 |
consultations: int # Número de consultas (ex: 3)
|
| 70 |
|
| 71 |
+
# ==================== FUNÇÕES DE NOTIFICAÇÃO PUSH ====================
|
| 72 |
+
|
| 73 |
+
def short_collapse_key(keyword: str, sender_id: str, receiver_id: str) -> str:
|
| 74 |
+
"""Gera uma chave de colapso curta para notificações"""
|
| 75 |
+
raw = f"{keyword}:{sender_id}:{receiver_id}"
|
| 76 |
+
return hashlib.sha1(raw.encode()).hexdigest()[:20]
|
| 77 |
+
|
| 78 |
+
async def fetch_supabase_async(table: str, select: str, filters: dict, headers=SUPABASE_ROLE_HEADERS):
|
| 79 |
+
"""Função assíncrona para buscar dados do Supabase"""
|
| 80 |
+
filter_query = '&'.join([f'{k}=eq.{v}' for k, v in filters.items()])
|
| 81 |
+
url = f"{SUPABASE_URL}/rest/v1/{table}?select={select}&{filter_query}&order=created_at.desc"
|
| 82 |
+
|
| 83 |
+
async with aiohttp.ClientSession() as session:
|
| 84 |
+
async with session.get(url, headers=headers) as resp:
|
| 85 |
+
if resp.status != 200:
|
| 86 |
+
detail = await resp.text()
|
| 87 |
+
raise HTTPException(status_code=500, detail=f"Supabase error: {detail}")
|
| 88 |
+
return await resp.json()
|
| 89 |
+
|
| 90 |
+
def format_name(full_name: str) -> str:
|
| 91 |
+
"""Formata o nome para exibição (Nome + inicial do sobrenome)"""
|
| 92 |
+
parts = full_name.strip().split()
|
| 93 |
+
if len(parts) == 1:
|
| 94 |
+
return parts[0]
|
| 95 |
+
return f"{parts[0]} {parts[1][0].upper()}."
|
| 96 |
+
|
| 97 |
+
async def get_user_info_async(user_id: str):
|
| 98 |
+
"""Busca informações do usuário de forma assíncrona"""
|
| 99 |
+
users = await fetch_supabase_async("User", "name,token_fcm", {"id": user_id})
|
| 100 |
+
if not users:
|
| 101 |
+
return None
|
| 102 |
+
return users[0]
|
| 103 |
+
|
| 104 |
+
def get_access_token():
|
| 105 |
+
"""Obtém token de acesso para Firebase Cloud Messaging"""
|
| 106 |
+
credentials = service_account.Credentials.from_service_account_file(
|
| 107 |
+
SERVICE_ACCOUNT_FILE
|
| 108 |
+
)
|
| 109 |
+
scoped_credentials = credentials.with_scopes(
|
| 110 |
+
['https://www.googleapis.com/auth/firebase.messaging']
|
| 111 |
+
)
|
| 112 |
+
scoped_credentials.refresh(GoogleRequest())
|
| 113 |
+
return scoped_credentials.token
|
| 114 |
+
|
| 115 |
+
async def send_push_notification(sender_id: str, target_user_id: str, keyword: str = "changeprice"):
|
| 116 |
+
"""Envia notificação push para um usuário específico"""
|
| 117 |
+
try:
|
| 118 |
+
# Buscar informações do usuário alvo
|
| 119 |
+
target_user = await get_user_info_async(target_user_id)
|
| 120 |
+
if not target_user or not target_user.get("token_fcm"):
|
| 121 |
+
logger.warning(f"⚠️ FCM token not found for user {target_user_id}")
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
# Buscar informações do remetente (estilista)
|
| 125 |
+
actor_info = await get_user_info_async(sender_id)
|
| 126 |
+
if not actor_info or not actor_info.get("name"):
|
| 127 |
+
logger.warning(f"⚠️ Actor info not found for user {sender_id}")
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
actor_name = format_name(actor_info["name"])
|
| 131 |
+
collapse_id = short_collapse_key(keyword, sender_id, target_user_id)
|
| 132 |
+
|
| 133 |
+
# Configurar conteúdo da notificação
|
| 134 |
+
title = "⚠️ Subscription Price Changed"
|
| 135 |
+
body = f"{actor_name} changed your subscription price. Your subscription was automatically canceled. Please check the chat with {actor_name} for reactivation options and more info."
|
| 136 |
+
|
| 137 |
+
# Montar mensagem FCM
|
| 138 |
+
message = {
|
| 139 |
+
"notification": {
|
| 140 |
+
"title": title,
|
| 141 |
+
"body": body,
|
| 142 |
+
},
|
| 143 |
+
"token": target_user["token_fcm"],
|
| 144 |
+
"android": {
|
| 145 |
+
"collapse_key": collapse_id,
|
| 146 |
+
"notification": {
|
| 147 |
+
"tag": collapse_id
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
"apns": {
|
| 151 |
+
"headers": {
|
| 152 |
+
"apns-collapse-id": collapse_id
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
payload = {"message": message}
|
| 158 |
+
|
| 159 |
+
# Enviar notificação
|
| 160 |
+
access_token = get_access_token()
|
| 161 |
+
headers = {
|
| 162 |
+
"Authorization": f"Bearer {access_token}",
|
| 163 |
+
"Content-Type": "application/json"
|
| 164 |
+
}
|
| 165 |
+
url = f"https://fcm.googleapis.com/v1/projects/{FCM_PROJECT_ID}/messages:send"
|
| 166 |
+
|
| 167 |
+
async with aiohttp.ClientSession() as session:
|
| 168 |
+
async with session.post(url, headers=headers, json=payload) as resp:
|
| 169 |
+
if resp.status == 200:
|
| 170 |
+
logger.info(f"✅ Push notification sent to user {target_user_id}")
|
| 171 |
+
return True
|
| 172 |
+
else:
|
| 173 |
+
resp_text = await resp.text()
|
| 174 |
+
logger.error(f"❌ FCM error for user {target_user_id}: {resp_text}")
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"❌ Error sending push notification to {target_user_id}: {e}")
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
async def send_bulk_push_notifications(sender_id: str, target_user_ids: list):
|
| 182 |
+
"""Envia notificações push para múltiplos usuários"""
|
| 183 |
+
if not target_user_ids:
|
| 184 |
+
logger.info("📭 No users to notify")
|
| 185 |
+
return
|
| 186 |
+
|
| 187 |
+
logger.info(f"📤 Sending push notifications to {len(target_user_ids)} users")
|
| 188 |
+
|
| 189 |
+
# Enviar notificações em paralelo
|
| 190 |
+
tasks = [send_push_notification(sender_id, user_id) for user_id in target_user_ids]
|
| 191 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 192 |
+
|
| 193 |
+
success_count = sum(1 for result in results if result is True)
|
| 194 |
+
logger.info(f"✅ Successfully sent {success_count}/{len(target_user_ids)} push notifications")
|
| 195 |
+
|
| 196 |
+
# ==================== FUNÇÕES ORIGINAIS ====================
|
| 197 |
+
|
| 198 |
def get_active_subscribers_by_price_id(price_id: str) -> list:
|
| 199 |
"""
|
| 200 |
Retorna uma lista de customer_ids que têm uma assinatura ativa com o price_id fornecido.
|
|
|
|
| 1111 |
if update_response.status_code not in [200, 204]:
|
| 1112 |
raise HTTPException(status_code=500, detail=f"Failed to update user: {update_response.text}")
|
| 1113 |
|
| 1114 |
+
# 🔥 Cria notificações na base de dados para os afetados
|
| 1115 |
create_notifications_for_price_change(affected_users, stylist_id=user_id)
|
| 1116 |
|
| 1117 |
+
# 🚀 NOVA FUNCIONALIDADE: Enviar notificações push para todos os afetados
|
| 1118 |
+
if affected_users:
|
| 1119 |
+
try:
|
| 1120 |
+
await send_bulk_push_notifications(sender_id=user_id, target_user_ids=affected_users)
|
| 1121 |
+
logger.info(f"🔔 Push notifications process completed for {len(affected_users)} users")
|
| 1122 |
+
except Exception as push_error:
|
| 1123 |
+
logger.error(f"⚠️ Error sending push notifications: {push_error}")
|
| 1124 |
+
# Não falha a operação principal se as notificações push falharem
|
| 1125 |
+
|
| 1126 |
+
logger.info(f"✅ User updated, notifications created and push notifications sent")
|
| 1127 |
|
| 1128 |
+
return {
|
| 1129 |
+
"message": "Price created and user updated successfully!",
|
| 1130 |
+
"price_id": new_price_id,
|
| 1131 |
+
"affected_users_count": len(affected_users),
|
| 1132 |
+
"notifications_sent": True
|
| 1133 |
+
}
|
| 1134 |
|
| 1135 |
except Exception as e:
|
| 1136 |
logger.error(f"❌ Error creating price: {e}")
|
| 1137 |
raise HTTPException(status_code=500, detail=f"Error creating price: {str(e)}")
|
| 1138 |
+
|
| 1139 |
@router.post("/emergency_checkout_session")
|
| 1140 |
def emergency_checkout_session(
|
| 1141 |
data: EmergencyPaymentRequest,
|