Fill in missing Resend templates + Freescout support links
Closes gaps identified after Phase 3: the four email templates named in CLAUDE.md's file tree (§5) were only partially built (weekly brief only). Adds: - welcome.html, trial_reminder.html, price_alert.html, new_product.html — matching the brief.html styling pattern - services/email_service.py — send_welcome_email(), send_trial_reminder_email(), send_price_alert_email(), send_new_product_email(), all via the same plain-Jinja2 + Resend pattern as brief_service.py (side-effect failures logged, never raised) - Three new internal routes for n8n/manual-onboarding to call: POST /internal/welcome-email/<tenant_id> POST /internal/trial-reminder/<tenant_id> POST /internal/alert-email/<alert_id> (dispatches to price_alert or new_product template by alert_type, marks delivered_email on success) - alert_repository.mark_email_delivered() to back that last route Also adds the "Contact support" (Freescout) placeholder link CLAUDE.md calls for in both the sidebar (visible on every page) and AccountPage, pointing at support.bettersight.io. The actual Freescout container, DNS/Pangolin routing, and mailbox are real infra this backend can't provision — the link is a placeholder until that's deployed. 216 backend tests passing, 95% coverage, 100% on the new email_service. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
11610b8b4a
commit
c0a77a1a65
|
|
@ -26,6 +26,7 @@ from services import onboarding_service
|
|||
from services import billing_service
|
||||
from services import auth_service
|
||||
from services import analysis_service
|
||||
from services import email_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -777,6 +778,66 @@ def internal_weekly_brief(tenant_id):
|
|||
return jsonify({'status': 'ok'})
|
||||
|
||||
|
||||
@api_bp.route('/internal/welcome-email/<tenant_id>', methods=['POST'])
|
||||
@require_api_key
|
||||
def internal_welcome_email(tenant_id):
|
||||
"""Called once during onboarding (manual runbook per §32, or a future signup webhook)."""
|
||||
tenant = tenant_repository.get_by_id(tenant_id)
|
||||
if not tenant:
|
||||
return error_response('not_found', 'Unknown tenant', 404)
|
||||
data = request.json or {}
|
||||
sent = email_service.send_welcome_email(tenant['email'], tenant['name'], data.get('template_url'))
|
||||
return jsonify({'sent': sent})
|
||||
|
||||
|
||||
@api_bp.route('/internal/trial-reminder/<tenant_id>', methods=['POST'])
|
||||
@require_api_key
|
||||
def internal_trial_reminder(tenant_id):
|
||||
"""Called by the n8n daily cron 2 days before trial expiry (CLAUDE.md §21)."""
|
||||
from datetime import datetime, timezone
|
||||
tenant = tenant_repository.get_by_id(tenant_id)
|
||||
if not tenant or not tenant.get('trial_ends_at'):
|
||||
return error_response('not_found', 'Unknown tenant or no active trial', 404)
|
||||
|
||||
trial_end = datetime.fromisoformat(tenant['trial_ends_at'].replace('Z', '+00:00'))
|
||||
days_left = max(0, (trial_end - datetime.now(timezone.utc)).days)
|
||||
sent = email_service.send_trial_reminder_email(
|
||||
tenant['email'], tenant['name'], days_left, trial_end.strftime('%Y-%m-%d')
|
||||
)
|
||||
return jsonify({'sent': sent})
|
||||
|
||||
|
||||
@api_bp.route('/internal/alert-email/<alert_id>', methods=['POST'])
|
||||
@require_api_key
|
||||
def internal_alert_email(alert_id):
|
||||
"""
|
||||
Sends an individual price-change or new-product alert email —
|
||||
called by the n8n alert-creation workflow (CLAUDE.md §15) for
|
||||
alerts significant enough to warrant an immediate email rather
|
||||
than waiting for the daily digest.
|
||||
"""
|
||||
alert = alert_repository.get_by_id(alert_id)
|
||||
if not alert:
|
||||
return error_response('not_found', 'Unknown alert', 404)
|
||||
|
||||
tenant = tenant_repository.get_by_id(alert['tenant_id'])
|
||||
competitor = competitor_repository.get_by_id(alert['competitor_id'])
|
||||
if not tenant or not competitor:
|
||||
return error_response('not_found', 'Tenant or competitor no longer exists', 404)
|
||||
|
||||
enriched = {**alert, 'competitor_name': competitor['name']}
|
||||
if alert['alert_type'] == 'price_change':
|
||||
sent = email_service.send_price_alert_email(tenant['email'], enriched)
|
||||
elif alert['alert_type'] == 'new_product':
|
||||
sent = email_service.send_new_product_email(tenant['email'], enriched)
|
||||
else:
|
||||
return error_response('unsupported_alert_type', f'No email template for {alert["alert_type"]}', 400)
|
||||
|
||||
if sent:
|
||||
alert_repository.mark_email_delivered(alert_id)
|
||||
return jsonify({'sent': sent})
|
||||
|
||||
|
||||
@api_bp.route('/internal/monitor-webhook', methods=['POST'])
|
||||
@require_api_key
|
||||
def monitor_webhook():
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class AlertRepository:
|
|||
def mark_read(self, alert_id):
|
||||
return pb_client.update(COLLECTION, alert_id, {'read': True})
|
||||
|
||||
def mark_email_delivered(self, alert_id):
|
||||
return pb_client.update(COLLECTION, alert_id, {'delivered_email': True})
|
||||
|
||||
|
||||
class MockAlertRepository:
|
||||
"""In-memory stand-in for AlertRepository."""
|
||||
|
|
@ -55,5 +58,11 @@ class MockAlertRepository:
|
|||
self._records[alert_id]['read'] = True
|
||||
return self._records[alert_id]
|
||||
|
||||
def mark_email_delivered(self, alert_id):
|
||||
if alert_id not in self._records:
|
||||
return None
|
||||
self._records[alert_id]['delivered_email'] = True
|
||||
return self._records[alert_id]
|
||||
|
||||
|
||||
alert_repository = MockAlertRepository() if USE_MOCK_REPOSITORIES else AlertRepository()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RESEND_API_KEY = os.getenv('RESEND_API_KEY')
|
||||
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
|
||||
|
||||
# Same reasoning as brief_service.py: plain Jinja2, not flask.render_template,
|
||||
# so this module has no dependency on an active Flask app context.
|
||||
_jinja_env = Environment(
|
||||
loader=FileSystemLoader(TEMPLATES_DIR),
|
||||
autoescape=select_autoescape(['html'])
|
||||
)
|
||||
|
||||
|
||||
def _send(template_name, to_email, subject, context, from_address='alerts@bettersight.io'):
|
||||
"""
|
||||
Renders a Jinja2 template and sends it via Resend. A failed send is
|
||||
a side-effect failure — logged only, never raised into the
|
||||
caller's primary operation (signup completing, an alert being
|
||||
created, etc.) per CLAUDE.md §18 resilience rules.
|
||||
|
||||
Data flow:
|
||||
template_name + context → Jinja2 render → Resend Emails.send() →
|
||||
True on success / False on failure (logged)
|
||||
"""
|
||||
try:
|
||||
import resend
|
||||
resend.api_key = RESEND_API_KEY
|
||||
template = _jinja_env.get_template(template_name)
|
||||
html = template.render(**context)
|
||||
resend.Emails.send({
|
||||
'from': f'Bettersight <{from_address}>',
|
||||
'to': to_email,
|
||||
'subject': subject,
|
||||
'html': html,
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send {template_name} to {to_email}: {str(e)}')
|
||||
return False
|
||||
|
||||
|
||||
def send_welcome_email(to_email, tenant_name, template_url=None):
|
||||
"""
|
||||
Sends the branded welcome email on signup with a Sheet template
|
||||
download link. Data flow: tenant email + name + template URL →
|
||||
welcome.html rendered → Resend API → inbox (CLAUDE.md §21).
|
||||
"""
|
||||
return _send('welcome.html', to_email, 'Welcome to Bettersight — you\'re all set', {
|
||||
'name': tenant_name,
|
||||
'template_url': template_url,
|
||||
})
|
||||
|
||||
|
||||
def send_trial_reminder_email(to_email, tenant_name, days_left, trial_end_date):
|
||||
"""
|
||||
Sends the "trial ends in N days" reminder — fired 2 days before
|
||||
expiry per CLAUDE.md §21 Trial Periods ("n8n fires trial_reminder
|
||||
email (2 days before expiry)").
|
||||
"""
|
||||
return _send('trial_reminder.html', to_email, f'Your Bettersight trial ends in {days_left} days', {
|
||||
'name': tenant_name,
|
||||
'days_left': days_left,
|
||||
'trial_end_date': trial_end_date,
|
||||
})
|
||||
|
||||
|
||||
def send_price_alert_email(to_email, alert):
|
||||
"""
|
||||
Sends an individual price-change alert email. `alert` is an
|
||||
alerts-collection record (competitor name resolved by the caller
|
||||
since alerts only store competitor_id).
|
||||
|
||||
Data flow:
|
||||
alert dict → price_alert.html rendered → Resend API → inbox
|
||||
"""
|
||||
previous_price = None
|
||||
if alert.get('change_amount') is not None and alert.get('majority_price_usd') is not None:
|
||||
previous_price = alert['majority_price_usd'] - alert['change_amount']
|
||||
|
||||
return _send('price_alert.html', to_email, f'Price alert — {alert.get("competitor_name", "a competitor")}', {
|
||||
'competitor_name': alert.get('competitor_name'),
|
||||
'trip_name': alert.get('trip_name'),
|
||||
'previous_price': previous_price,
|
||||
'current_price': alert.get('majority_price_usd'),
|
||||
'change_percent': alert.get('change_percent'),
|
||||
})
|
||||
|
||||
|
||||
def send_new_product_email(to_email, alert):
|
||||
"""
|
||||
Sends an individual new-product alert email. Data flow: alert dict
|
||||
→ new_product.html rendered → Resend API → inbox.
|
||||
"""
|
||||
return _send('new_product.html', to_email, f'New product — {alert.get("competitor_name", "a competitor")}', {
|
||||
'competitor_name': alert.get('competitor_name'),
|
||||
'trip_name': alert.get('trip_name'),
|
||||
'destination': alert.get('destination'),
|
||||
'duration_days': alert.get('duration_days'),
|
||||
'price_usd': alert.get('majority_price_usd'),
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bettersight new product alert</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
|
||||
<tr>
|
||||
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
|
||||
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
|
||||
<div style="font-size:13px;color:#6B7A99;margin-top:4px;">New product alert</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<h1 style="font-size:16px;margin:0 0 14px;">
|
||||
{{ competitor_name }} added a new product
|
||||
<span style="background:#E0F8FF;color:#0284C7;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;">NEW</span>
|
||||
</h1>
|
||||
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 16px;">
|
||||
<strong>{{ trip_name }}</strong><br>
|
||||
{{ destination }} · {{ duration_days }} days
|
||||
{% if price_usd %} · ${{ price_usd }} USD{% endif %}
|
||||
</p>
|
||||
<a href="https://app.bettersight.io/activity" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
|
||||
View in dashboard →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
|
||||
Bettersight · Better sight, better moves.
|
||||
<br>
|
||||
<a href="https://app.bettersight.io/account" style="color:#9BA8C0;">Manage notification preferences</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bettersight price alert</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
|
||||
<tr>
|
||||
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
|
||||
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
|
||||
<div style="font-size:13px;color:#6B7A99;margin-top:4px;">Price alert</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<h1 style="font-size:16px;margin:0 0 14px;">
|
||||
{{ competitor_name }}
|
||||
{% if change_percent is not none and change_percent < 0 %}dropped{% else %}raised{% endif %}
|
||||
{{ trip_name }}
|
||||
{% if change_percent is not none %}{{ change_percent }}%{% endif %}
|
||||
</h1>
|
||||
<table cellpadding="0" cellspacing="0" style="font-size:13px;margin-bottom:16px;">
|
||||
<tr>
|
||||
<td style="padding:4px 12px 4px 0;color:#6B7A99;">Previous</td>
|
||||
<td style="padding:4px 0;font-weight:700;">${{ previous_price }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 12px 4px 0;color:#6B7A99;">Current</td>
|
||||
<td style="padding:4px 0;font-weight:700;color:{{ '#F04438' if change_percent is not none and change_percent < 0 else '#059669' }};">
|
||||
${{ current_price }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a href="https://app.bettersight.io/activity" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
|
||||
View in dashboard →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
|
||||
Bettersight · Better sight, better moves.
|
||||
<br>
|
||||
<a href="https://app.bettersight.io/account" style="color:#9BA8C0;">Manage notification preferences</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Your Bettersight trial is ending soon</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
|
||||
<tr>
|
||||
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
|
||||
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<h1 style="font-size:18px;margin:0 0 14px;">
|
||||
Your trial ends in {{ days_left }} day{{ '' if days_left == 1 else 's' }}
|
||||
</h1>
|
||||
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 18px;">
|
||||
Hi {{ name }},<br><br>
|
||||
Your Bettersight trial ends on {{ trial_end_date }}. Your subscription
|
||||
will continue automatically on that date unless you cancel.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#6B7A99;line-height:1.6;margin:0 0 20px;">
|
||||
If you've hit any friction at all, reply to this email — we're happy
|
||||
to help you get the most out of Bettersight before the trial ends.
|
||||
</p>
|
||||
<a href="https://app.bettersight.io/account" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
|
||||
Manage your subscription →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
|
||||
Bettersight · Better sight, better moves.
|
||||
<br>
|
||||
Questions? Reply to this email or visit
|
||||
<a href="https://support.bettersight.io" style="color:#9BA8C0;">support.bettersight.io</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Welcome to Bettersight</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
|
||||
<tr>
|
||||
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
|
||||
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<h1 style="font-size:18px;margin:0 0 14px;">Welcome to Bettersight — you're all set</h1>
|
||||
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 18px;">
|
||||
Hi {{ name }},<br><br>
|
||||
You're set up and ready to go. Here's how to get started in 5 minutes:
|
||||
</p>
|
||||
<ol style="font-size:13.5px;color:#2D3A52;line-height:2;margin:0 0 20px;padding-left:20px;">
|
||||
<li>Sign in at <a href="https://app.bettersight.io" style="color:#1B8EF8;">app.bettersight.io</a></li>
|
||||
<li>Add your first competitor (Competitors → + Add Competitor)</li>
|
||||
<li>Run your first analysis (Analyse → describe your trip)</li>
|
||||
<li>Push the results to your Google Sheet</li>
|
||||
</ol>
|
||||
<p style="font-size:13px;color:#6B7A99;line-height:1.6;margin:0 0 20px;">
|
||||
The guided tour will walk you through each step when you first log in.
|
||||
</p>
|
||||
{% if template_url %}
|
||||
<a href="{{ template_url }}" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
|
||||
Download your Sheet template →
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
|
||||
Bettersight · Better sight, better moves.
|
||||
<br>
|
||||
Questions? Reply to this email or visit
|
||||
<a href="https://support.bettersight.io" style="color:#9BA8C0;">support.bettersight.io</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import sys
|
||||
import types
|
||||
from services import email_service
|
||||
|
||||
|
||||
def _install_fake_resend(monkeypatch, raise_error=False):
|
||||
fake = types.ModuleType('resend')
|
||||
fake.api_key = None
|
||||
calls = []
|
||||
|
||||
class _Emails:
|
||||
@staticmethod
|
||||
def send(payload):
|
||||
if raise_error:
|
||||
raise RuntimeError('resend api down')
|
||||
calls.append(payload)
|
||||
|
||||
fake.Emails = _Emails
|
||||
monkeypatch.setitem(sys.modules, 'resend', fake)
|
||||
return calls
|
||||
|
||||
|
||||
def test_send_welcome_email_renders_and_sends(monkeypatch):
|
||||
calls = _install_fake_resend(monkeypatch)
|
||||
|
||||
result = email_service.send_welcome_email(
|
||||
'pm@gadventures.com', 'G Adventures', template_url='https://sheets.google.com/template'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['to'] == 'pm@gadventures.com'
|
||||
assert 'G Adventures' in calls[0]['html']
|
||||
assert 'template' in calls[0]['html']
|
||||
|
||||
|
||||
def test_send_welcome_email_without_template_url(monkeypatch):
|
||||
calls = _install_fake_resend(monkeypatch)
|
||||
result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures')
|
||||
assert result is True
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_send_trial_reminder_email(monkeypatch):
|
||||
calls = _install_fake_resend(monkeypatch)
|
||||
|
||||
result = email_service.send_trial_reminder_email('pm@gadventures.com', 'G Adventures', 2, '2026-07-14')
|
||||
|
||||
assert result is True
|
||||
assert '2' in calls[0]['subject']
|
||||
assert '2026-07-14' in calls[0]['html']
|
||||
|
||||
|
||||
def test_send_price_alert_email_computes_previous_price(monkeypatch):
|
||||
calls = _install_fake_resend(monkeypatch)
|
||||
|
||||
result = email_service.send_price_alert_email('pm@gadventures.com', {
|
||||
'competitor_name': 'Intrepid Travel', 'trip_name': 'Peru Classic',
|
||||
'majority_price_usd': 2190, 'change_amount': -300, 'change_percent': -12,
|
||||
})
|
||||
|
||||
assert result is True
|
||||
assert 'Intrepid Travel' in calls[0]['subject']
|
||||
assert '2490' in calls[0]['html'] # previous = 2190 - (-300)
|
||||
assert '2190' in calls[0]['html']
|
||||
|
||||
|
||||
def test_send_new_product_email(monkeypatch):
|
||||
calls = _install_fake_resend(monkeypatch)
|
||||
|
||||
result = email_service.send_new_product_email('pm@gadventures.com', {
|
||||
'competitor_name': 'Flash Pack', 'trip_name': 'Jordan Desert Explorer',
|
||||
'destination': 'Jordan', 'duration_days': 8, 'majority_price_usd': 3200,
|
||||
})
|
||||
|
||||
assert result is True
|
||||
assert 'Flash Pack' in calls[0]['subject']
|
||||
assert 'Jordan Desert Explorer' in calls[0]['html']
|
||||
|
||||
|
||||
def test_send_returns_false_and_logs_on_failure(monkeypatch):
|
||||
_install_fake_resend(monkeypatch, raise_error=True)
|
||||
result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures')
|
||||
assert result is False
|
||||
|
|
@ -242,6 +242,102 @@ def test_internal_weekly_brief(client, api_headers, monkeypatch):
|
|||
assert called == ['t1']
|
||||
|
||||
|
||||
def test_internal_welcome_email_unknown_tenant_404(client, api_headers):
|
||||
response = client.post('/internal/welcome-email/does-not-exist', headers=api_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_internal_welcome_email_sends(client, api_headers, monkeypatch):
|
||||
tenant = _make_tenant()
|
||||
monkeypatch.setattr('api.email_service.send_welcome_email', lambda email, name, url=None: True)
|
||||
|
||||
response = client.post(
|
||||
f'/internal/welcome-email/{tenant["id"]}', json={'template_url': 'https://sheets/x'}, headers=api_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json['sent'] is True
|
||||
|
||||
|
||||
def test_internal_trial_reminder_no_active_trial_404(client, api_headers):
|
||||
tenant = _make_tenant(trial_ends_at=None)
|
||||
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_internal_trial_reminder_sends(client, api_headers, monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
tenant = _make_tenant(trial_ends_at=(datetime.now(timezone.utc) + timedelta(days=2)).isoformat())
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
'api.email_service.send_trial_reminder_email',
|
||||
lambda email, name, days_left, trial_end_date: captured.update(days_left=days_left) or True
|
||||
)
|
||||
|
||||
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json['sent'] is True
|
||||
assert captured['days_left'] in (1, 2) # datetime.days truncation can land on either
|
||||
|
||||
|
||||
def test_internal_alert_email_unknown_alert_404(client, api_headers):
|
||||
response = client.post('/internal/alert-email/does-not-exist', headers=api_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_internal_alert_email_unsupported_type_400(client, api_headers):
|
||||
tenant = _make_tenant()
|
||||
competitor = competitor_repository.create({
|
||||
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
|
||||
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
|
||||
})
|
||||
alert = alert_repository.create({
|
||||
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_comparable', 'message': 'x',
|
||||
})
|
||||
|
||||
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json['code'] == 'unsupported_alert_type'
|
||||
|
||||
|
||||
def test_internal_alert_email_sends_price_change_and_marks_delivered(client, api_headers, monkeypatch):
|
||||
tenant = _make_tenant()
|
||||
competitor = competitor_repository.create({
|
||||
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
|
||||
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
|
||||
})
|
||||
alert = alert_repository.create({
|
||||
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'price_change',
|
||||
'message': 'Price dropped', 'change_percent': -12,
|
||||
})
|
||||
monkeypatch.setattr('api.email_service.send_price_alert_email', lambda email, alert_data: True)
|
||||
|
||||
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json['sent'] is True
|
||||
assert alert_repository.get_by_id(alert['id'])['delivered_email'] is True
|
||||
|
||||
|
||||
def test_internal_alert_email_sends_new_product(client, api_headers, monkeypatch):
|
||||
tenant = _make_tenant()
|
||||
competitor = competitor_repository.create({
|
||||
'tenant_id': tenant['id'], 'name': 'Flash Pack', 'website': 'https://flashpack.com',
|
||||
'catalogue_url': 'https://flashpack.com/adventures', 'primary_market': 'US', 'active': True,
|
||||
})
|
||||
alert = alert_repository.create({
|
||||
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_product', 'message': 'New trip',
|
||||
})
|
||||
monkeypatch.setattr('api.email_service.send_new_product_email', lambda email, alert_data: True)
|
||||
|
||||
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json['sent'] is True
|
||||
|
||||
|
||||
def test_internal_monitor_webhook_flags_competitor(client, api_headers):
|
||||
tenant = _make_tenant()
|
||||
competitor = competitor_repository.create({
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ const planLabel = computed(() => {
|
|||
</nav>
|
||||
|
||||
<div class="sb-foot">
|
||||
<a
|
||||
href="https://support.bettersight.io"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="display:block;padding:8px 10px 4px;font-size:11px;color:rgba(255,255,255,.38);text-decoration:none;"
|
||||
>
|
||||
Contact support ↗
|
||||
</a>
|
||||
<RouterLink to="/account" class="sb-user">
|
||||
<div class="sb-av">{{ initials }}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ function replayTour() {
|
|||
@remove="removeSeat"
|
||||
/>
|
||||
|
||||
<div class="card" style="padding:18px 20px;">
|
||||
<div class="card" style="padding:18px 20px;display:flex;flex-direction:column;gap:10px;">
|
||||
<p
|
||||
v-if="authStore.onboarding.tour_skipped || authStore.onboarding.tour_completed"
|
||||
style="font-size:12.5px;color:var(--blue-vivid);cursor:pointer;margin:0;"
|
||||
|
|
@ -132,6 +132,14 @@ function replayTour() {
|
|||
>
|
||||
↺ Take the guided tour again
|
||||
</p>
|
||||
<a
|
||||
href="https://support.bettersight.io"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="font-size:12.5px;color:var(--t3);text-decoration:none;"
|
||||
>
|
||||
Contact support ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue