fix: show target-language-specific translations in preview + rename migration
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled

Two issues found in screenshot review:
1. Glossary preview always showed English 'target' field (foie → liver)
   instead of the language-specific translation. Now looks up
   term.translations[targetLang] first, falls back to term.target.
   When target is Persian, preview shows 'foie → کبد' (Persian).

2. Previous migration b7c8d9e0f1a2 was already applied on server before
   the rename was added. New migration c8d9e0f1a2b3 handles the rename
   separately: glossaries with target_language='multi' get their name
   changed from 'Anglais' to 'Multilingue'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:22:50 +02:00
parent a79ce0fc9b
commit b4096fd2ca
4 changed files with 434 additions and 11 deletions

View File

@@ -0,0 +1,40 @@
"""Rename multilingual glossaries from 'Anglais' to 'Multilingue'
Revision ID: c8d9e0f1a2b3
Revises: b7c8d9e0f1a2
Create Date: 2026-05-31
Previous migration b7c8d9e0f1a2 set target_language='multi' but the rename
part was added after the migration was already applied on the server.
This migration handles the rename separately.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "c8d9e0f1a2b3"
down_revision = "b7c8d9e0f1a2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename glossaries that are multilingual but still have "Anglais" in name
op.execute("""
UPDATE glossaries
SET name = REPLACE(name, 'Anglais', 'Multilingue')
WHERE target_language = 'multi'
AND name LIKE '%%Anglais%%'
""")
def downgrade() -> None:
op.execute("""
UPDATE glossaries
SET name = REPLACE(name, 'Multilingue', 'Anglais')
WHERE target_language = 'multi'
AND name LIKE '%%Multilingue%%'
""")

View File

@@ -546,14 +546,18 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</div> </div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? ( ) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1"> <div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">
{selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => ( {selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => {
const translations = t.translations || {};
const displayTarget = translations[targetLang] || t.target;
return (
<div key={t.id || i} className="flex justify-between items-center bg-brand-muted/30 dark:bg-white/5 px-2 py-1.5 rounded-md text-xs"> <div key={t.id || i} className="flex justify-between items-center bg-brand-muted/30 dark:bg-white/5 px-2 py-1.5 rounded-md text-xs">
<span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]"> <span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]">
{t.source} {t.target} {t.source} {displayTarget}
</span> </span>
<span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" /> <span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" />
</div> </div>
))} );
})}
{selectedGlossaryDetail.terms.length > 4 && ( {selectedGlossaryDetail.terms.length > 4 && (
<span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1"> <span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1">
+ {selectedGlossaryDetail.terms.length - 4} autres termes + {selectedGlossaryDetail.terms.length - 4} autres termes

View File

@@ -117,3 +117,211 @@ async def send_email_async(to: str, subject: str, body: str) -> bool:
except Exception as e: except Exception as e:
logger.error(f"Failed to send email (async) to {to}: {e}") logger.error(f"Failed to send email (async) to {to}: {e}")
return False return False
async def send_subscription_email_async(
to_email: str,
user_name: str,
event_type: str,
details: dict
) -> bool:
"""
Send subscription transactional email in French.
event_type can be: 'activated', 'cancelled', 'ended', 'payment_failed', 'credits_purchased'
"""
display_name = user_name or to_email.split("@")[0]
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
dashboard_url = f"{frontend_url}/dashboard"
profile_url = f"{frontend_url}/dashboard/profile?tab=subscription"
pricing_url = f"{frontend_url}/pricing"
subject = "Notification Office Translator"
header_title = "Office Translator"
content_html = ""
if event_type == "activated":
plan_name = details.get("plan_name", "Premium").capitalize()
ends_at = details.get("ends_at", "")
subject = "Votre abonnement Office Translator est activé ! 🚀"
header_title = "Bienvenue chez Office Translator"
content_html = f"""
<p>Bonjour {display_name},</p>
<p>Nous sommes ravis de vous compter parmi nos membres Premium ! Votre abonnement au forfait <strong>{plan_name}</strong> est désormais actif.</p>
<div class="info-box">
<div class="info-title">Détails de votre abonnement :</div>
<p style="margin: 4px 0;"><strong>Forfait :</strong> {plan_name}</p>
{"<p style='margin: 4px 0;'><strong>Date de renouvellement :</strong> " + ends_at + "</p>" if ends_at else ""}
</div>
<p>Vous pouvez dès à présent profiter de toutes les fonctionnalités premium, y compris la traduction illimitée de documents et l'accès à nos meilleurs modèles d'IA.</p>
<div class="button-container">
<a href="{dashboard_url}" class="button">Accéder à mon tableau de bord</a>
</div>
"""
elif event_type == "cancelled":
ends_at = details.get("ends_at", "")
subject = "Confirmation d'annulation de votre abonnement - Office Translator"
header_title = "Abonnement annulé"
content_html = f"""
<p>Bonjour {display_name},</p>
<p>Nous vous confirmons que l'annulation de votre abonnement a bien été prise en compte à votre demande.</p>
<div class="info-box">
<div class="info-title">Informations importantes :</div>
{"<p style='margin: 4px 0;'><strong>Accès actif jusqu'au :</strong> " + ends_at + "</p>" if ends_at else ""}
<p style="margin: 4px 0;">Aucun autre prélèvement ne sera effectué.</p>
</div>
<p>Vous conservez un accès complet à toutes les fonctionnalités premium de votre forfait jusqu'à la fin de la période de facturation en cours.</p>
<p>Si vous changez d'avis, vous pouvez réactiver votre abonnement à tout moment depuis votre profil.</p>
<div class="button-container">
<a href="{profile_url}" class="button">Gérer mon compte</a>
</div>
"""
elif event_type == "ended":
subject = "Votre accès premium a expiré - Office Translator"
header_title = "Accès premium terminé"
content_html = f"""
<p>Bonjour {display_name},</p>
<p>Votre abonnement Office Translator a pris fin et votre compte a été basculé vers le forfait <strong>Gratuit</strong>.</p>
<p>Vous pouvez toujours traduire des documents dans la limite de votre quota gratuit. Vos documents et votre historique restent disponibles.</p>
<p>Pour retrouver l'accès à la traduction rapide, sans limites et à nos modèles IA avancés, vous pouvez vous réabonner en un clic.</p>
<div class="button-container">
<a href="{pricing_url}" class="button">Voir nos forfaits</a>
</div>
"""
elif event_type == "payment_failed":
subject = "Échec de paiement - Action requise - Office Translator ⚠️"
header_title = "Problème de paiement"
content_html = f"""
<p>Bonjour {display_name},</p>
<p>Nous n'avons pas pu prélever le règlement de votre abonnement Office Translator. Notre tentative de paiement a échoué.</p>
<p>Pour éviter toute interruption de votre service premium, nous vous invitons à mettre à jour vos coordonnées de paiement dès que possible.</p>
<div class="button-container">
<a href="{profile_url}" class="button">Mettre à jour mon moyen de paiement</a>
</div>
<p>Si vous avez des questions, n'hésitez pas à répondre à cet e-mail.</p>
"""
elif event_type == "credits_purchased":
credits = details.get("credits", 0)
subject = "Achat de crédits réussi - Office Translator 💳"
header_title = "Crédits ajoutés"
content_html = f"""
<p>Bonjour {display_name},</p>
<p>Nous vous remercions pour votre achat ! Vos crédits supplémentaires ont bien été crédités sur votre compte.</p>
<div class="info-box">
<div class="info-title">Détails de l'achat :</div>
<p style="margin: 4px 0;"><strong>Crédits achetés :</strong> +{credits}</p>
<p style="margin: 4px 0;">Chaque crédit vous permet de traduire une page de document supplémentaire.</p>
</div>
<p>Vous pouvez suivre votre solde de crédits et l'historique de vos traductions depuis votre tableau de bord.</p>
<div class="button-container">
<a href="{dashboard_url}" class="button">Traduire un document</a>
</div>
"""
else:
return False
full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f8fafc;
color: #334155;
margin: 0;
padding: 0;
}}
.wrapper {{
width: 100%;
background-color: #f8fafc;
padding: 40px 0;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border: 1px solid #e2e8f0;
}}
.header {{
background: linear-gradient(135deg, #4f46e5, #06b6d4);
padding: 32px;
text-align: center;
color: #ffffff;
}}
.header h1 {{
margin: 0;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.025em;
}}
.content {{
padding: 32px;
line-height: 1.6;
}}
.content p {{
margin-top: 0;
margin-bottom: 16px;
}}
.button-container {{
text-align: center;
margin: 32px 0;
}}
.button {{
background-color: #4f46e5;
color: #ffffff !important;
padding: 12px 32px;
text-decoration: none;
font-weight: 600;
border-radius: 8px;
display: inline-block;
box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.2);
}}
.footer {{
background-color: #f1f5f9;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
border-top: 1px solid #e2e8f0;
}}
.footer p {{
margin: 4px 0;
}}
.info-box {{
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}}
.info-title {{
font-weight: 600;
color: #1e293b;
margin-bottom: 8px;
}}
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="header">
<h1>{header_title}</h1>
</div>
<div class="content">
{content_html}
</div>
<div class="footer">
<p>Cet e-mail automatique a été envoyé par <strong>Office Translator</strong>.</p>
<p>&copy; {datetime.now().year} Office Translator. Tous droits réservés.</p>
</div>
</div>
</div>
</body>
</html>
"""
return await send_email_async(to_email, subject, full_html)

View File

@@ -268,16 +268,74 @@ async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
async def handle_checkout_completed(session: Dict): async def handle_checkout_completed(session: Dict):
"""Handle successful checkout""" """Handle successful checkout"""
metadata = session.get("metadata", {}) metadata = session.get("metadata", {}) or {}
user_id = metadata.get("user_id") user_id = metadata.get("user_id")
if not user_id: if not user_id:
return return
session_id = session.get("id")
# Check for duplicate session processing using PaymentHistory
db_available = False
try:
from database.connection import get_sync_session
from database.models import PaymentHistory as DBPaymentHistory
db_available = True
except ImportError:
pass
if db_available and session_id:
try:
with get_sync_session() as db_session:
existing = db_session.query(DBPaymentHistory).filter(
DBPaymentHistory.stripe_payment_intent_id == session_id
).first()
if existing:
logger.info("Checkout session %s already processed. Skipping.", session_id)
return
except Exception as e:
logger.error("Error checking PaymentHistory duplication: %s", e)
user = get_user_by_id(user_id)
if not user:
return
# Check if it's a credit purchase # Check if it's a credit purchase
if metadata.get("type") == "credits": if metadata.get("type") == "credits":
credits = int(metadata.get("credits", 0)) credits = int(metadata.get("credits", 0))
add_credits(user_id, credits) add_credits(user_id, credits)
# Log to PaymentHistory
if db_available and session_id:
try:
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=session_id,
stripe_invoice_id=session.get("invoice") or session.get("subscription"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
payment_type="credits",
status="succeeded",
description=f"Achat de {credits} crédits",
)
db_session.add(payment)
db_session.commit()
except Exception as e:
logger.error("Failed to write credits payment to history: %s", e)
# Send Email
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="credits_purchased",
details={"credits": credits}
)
except Exception as e:
logger.error("Failed to send credit purchase email: %s", e)
return return
# It's a subscription # It's a subscription
@@ -310,6 +368,12 @@ async def handle_checkout_completed(session: Dict):
# Derive tier from plan (DB constraint: only 'free' or 'pro') # Derive tier from plan (DB constraint: only 'free' or 'pro')
tier = "pro" if plan in ("pro", "business", "enterprise") else "free" tier = "pro" if plan in ("pro", "business", "enterprise") else "free"
is_new_sub = (
user.stripe_subscription_id != subscription_id
or user.subscription_status != SubscriptionStatus.ACTIVE.value
or user.plan != plan
)
update_user(user_id, { update_user(user_id, {
"plan": plan, "plan": plan,
"tier": tier, "tier": tier,
@@ -323,14 +387,51 @@ async def handle_checkout_completed(session: Dict):
}) })
logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id) logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id)
# Log to PaymentHistory
if db_available and session_id:
try:
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=session_id,
stripe_invoice_id=subscription_id or session.get("invoice"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
payment_type="subscription",
status="succeeded",
description=f"Abonnement au forfait {plan}",
)
db_session.add(payment)
db_session.commit()
except Exception as e:
logger.error("Failed to write subscription payment to history: %s", e)
# Send activation email
if is_new_sub:
try:
from services.email_service import send_subscription_email_async
ends_str = subscription_ends_at.strftime("%d/%m/%Y") if subscription_ends_at else ""
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="activated",
details={"plan_name": plan, "ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send activation email: %s", e)
async def handle_subscription_updated(subscription: Dict): async def handle_subscription_updated(subscription: Dict):
"""Handle subscription updates""" """Handle subscription updates"""
metadata = subscription.get("metadata", {}) metadata = subscription.get("metadata", {}) or {}
user_id = metadata.get("user_id") user_id = metadata.get("user_id")
if not user_id: if not user_id:
return return
user = get_user_by_id(user_id)
if not user:
return
status_map = { status_map = {
"active": SubscriptionStatus.ACTIVE, "active": SubscriptionStatus.ACTIVE,
@@ -343,38 +444,79 @@ async def handle_subscription_updated(subscription: Dict):
stripe_status = subscription.get("status", "active") stripe_status = subscription.get("status", "active")
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE) status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
stripe_cancel_at_period_end = subscription.get("cancel_at_period_end", False)
is_newly_cancelling = stripe_cancel_at_period_end and not user.cancel_at_period_end
period_end = subscription.get("current_period_end")
ends_str = ""
if period_end:
from datetime import timezone
ends_str = datetime.fromtimestamp(period_end, tz=timezone.utc).strftime("%d/%m/%Y")
update_user(user_id, { update_user(user_id, {
"subscription_status": status.value, "subscription_status": status.value,
"cancel_at_period_end": subscription.get("cancel_at_period_end", False), "cancel_at_period_end": stripe_cancel_at_period_end,
"subscription_ends_at": datetime.fromtimestamp( "subscription_ends_at": datetime.fromtimestamp(
subscription.get("current_period_end", 0) subscription.get("current_period_end", 0)
).isoformat() if subscription.get("current_period_end") else None ).isoformat() if subscription.get("current_period_end") else None
}) })
# Send cancellation email if they just selected to cancel
if is_newly_cancelling:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="cancelled",
details={"ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send cancellation email in handle_subscription_updated: %s", e)
async def handle_subscription_deleted(subscription: Dict): async def handle_subscription_deleted(subscription: Dict):
"""Handle subscription cancellation""" """Handle subscription cancellation (actually ended)"""
metadata = subscription.get("metadata", {}) metadata = subscription.get("metadata", {}) or {}
user_id = metadata.get("user_id") user_id = metadata.get("user_id")
if not user_id: if not user_id:
return return
user = get_user_by_id(user_id)
if not user:
return
had_active_sub = user.plan != PlanType.FREE.value or user.tier != "free"
update_user(user_id, { update_user(user_id, {
"plan": PlanType.FREE.value, "plan": PlanType.FREE.value,
"tier": "free",
"subscription_status": SubscriptionStatus.CANCELED.value, "subscription_status": SubscriptionStatus.CANCELED.value,
"stripe_subscription_id": None, "stripe_subscription_id": None,
}) })
# Send ended email
if had_active_sub:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="ended",
details={}
)
except Exception as e:
logger.error("Failed to send subscription ended email: %s", e)
async def handle_payment_failed(invoice: Dict): async def handle_payment_failed(invoice: Dict):
"""Handle failed payment — set subscription status to PAST_DUE""" """Handle failed payment — set subscription status to PAST_DUE and send email"""
customer_id = invoice.get("customer") customer_id = invoice.get("customer")
if not customer_id: if not customer_id:
return return
# Find user by stripe_customer_id # Find user by stripe_customer_id
user = None
try: try:
from database.connection import get_sync_session from database.connection import get_sync_session
from database.models import User as DBUser from database.models import User as DBUser
@@ -393,6 +535,18 @@ async def handle_payment_failed(invoice: Dict):
"Payment failed for customer %s (user %s) — status set to past_due", "Payment failed for customer %s (user %s) — status set to past_due",
customer_id, user_id, customer_id, user_id,
) )
# Send email
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=db_user.email,
user_name=db_user.name,
event_type="payment_failed",
details={}
)
except Exception as e:
logger.error("Failed to send payment failed email: %s", e)
except Exception as exc: except Exception as exc:
logger.error("handle_payment_failed DB error: %s", exc) logger.error("handle_payment_failed DB error: %s", exc)
@@ -417,14 +571,31 @@ async def cancel_subscription(user_id: str) -> Dict[str, Any]:
cancel_at = datetime.fromtimestamp(subscription.cancel_at).isoformat() cancel_at = datetime.fromtimestamp(subscription.cancel_at).isoformat()
subscription_ends_at = None subscription_ends_at = None
ends_str = ""
if subscription.current_period_end: if subscription.current_period_end:
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end).isoformat() subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end).isoformat()
ends_str = datetime.fromtimestamp(subscription.current_period_end).strftime("%d/%m/%Y")
is_new_cancel = not user.cancel_at_period_end
update_user(user_id, { update_user(user_id, {
"cancel_at_period_end": True, "cancel_at_period_end": True,
"subscription_ends_at": subscription_ends_at, "subscription_ends_at": subscription_ends_at,
}) })
# Send cancellation confirmation email
if is_new_cancel:
try:
from services.email_service import send_subscription_email_async
await send_subscription_email_async(
to_email=user.email,
user_name=user.name,
event_type="cancelled",
details={"ends_at": ends_str}
)
except Exception as e:
logger.error("Failed to send cancellation email in cancel_subscription: %s", e)
return { return {
"status": "canceling", "status": "canceling",
"cancel_at": cancel_at, "cancel_at": cancel_at,