import os import logging import time from functools import wraps from urllib.parse import urlparse import requests from flask import Blueprint, request, jsonify, Response from extensions import limiter from errors import error_response from repositories.tenant_repository import tenant_repository from repositories.seat_repository import seat_repository from repositories.competitor_repository import competitor_repository from repositories.scrape_repository import scrape_repository from repositories.battlecard_repository import battlecard_repository from repositories.comparable_repository import comparable_repository from repositories.alert_repository import alert_repository from services import licence_service from services import trip_finder_service from services import battlecard_service from services import brief_service 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__) api_bp = Blueprint('api', __name__) API_SECRET_KEY = os.getenv('API_SECRET_KEY') STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') TEMPLATE_SHEET_URL = os.getenv('TEMPLATE_SHEET_URL') FIRECRAWL_URL = os.getenv('FIRECRAWL_URL', 'http://firecrawl:3002') POCKETBASE_URL = os.getenv('POCKETBASE_URL', 'http://pocketbase:8090') # ── AUTH DECORATOR ─────────────────────────────────────────────────── def require_api_key(f): """ Rejects any request whose X-API-Key header doesn't match API_SECRET_KEY. Applied to every route except /health and /stripe/webhook (which verifies via Stripe's signature instead). """ @wraps(f) def wrapper(*args, **kwargs): if not API_SECRET_KEY or request.headers.get('X-API-Key') != API_SECRET_KEY: return error_response('unauthorized', 'Invalid or missing API key', 401) return f(*args, **kwargs) return wrapper # ── REQUEST LOGGING ─────────────────────────────────────────────────── @api_bp.before_app_request def _log_request_start(): request._start_time = time.time() @api_bp.after_app_request def _log_request_end(response): # Every route logs method/path/duration/status per CLAUDE.md §18 # observability rules. tenant_id isn't always known at this layer # (it's resolved deep inside individual routes), so it's omitted # here and logged inline wherever a route already has it. duration_ms = round((time.time() - getattr(request, '_start_time', time.time())) * 1000) logger.info(f'{request.method} {request.path} {response.status_code} {duration_ms}ms') return response # ── SHARED HELPERS ──────────────────────────────────────────────────── def _resolve_tenant_by_email(email): """Looks up the tenant owning the domain of `email`. Returns None if unresolvable.""" if not email or '@' not in email: return None domain = email.split('@', 1)[1].lower() return tenant_repository.get_by_domain(domain) def _competitor_status(competitor): """Maps a competitor record to the dashboard's ✓/⚠/○ status badge value.""" if competitor.get('change_detected'): return 'changed' if analysis_service.needs_refresh(competitor): return 'stale' return 'current' def _register_monitor(competitor_id, url): """ Registers a competitor URL with Firecrawl Monitor. Side-effect failure is logged only — a monitor registration failure must never block the competitor add operation itself (CLAUDE.md §18 resilience). Data flow: competitor_id + url → POST {FIRECRAWL_URL}/v1/monitor → monitor_id → competitors.firecrawl_monitor_id updated """ try: response = requests.post( f'{FIRECRAWL_URL}/v1/monitor', json={ 'url': url, 'webhook': 'https://api.bettersight.io/internal/monitor-webhook', 'changeTypes': ['content'], }, timeout=10 ) monitor_id = response.json().get('id') if monitor_id: competitor_repository.update(competitor_id, {'firecrawl_monitor_id': monitor_id}) except Exception as e: logger.error(f'Firecrawl monitor registration failed for {competitor_id}: {str(e)}') def _deregister_monitor(monitor_id): """Deregisters a Firecrawl monitor. Side-effect failure is logged only.""" if not monitor_id: return try: requests.delete(f'{FIRECRAWL_URL}/v1/monitor/{monitor_id}', timeout=10) except Exception as e: logger.error(f'Firecrawl monitor deregistration failed for {monitor_id}: {str(e)}') def _extract_domain(url): netloc = urlparse(url if '://' in url else f'//{url}').netloc or url return netloc.lower().lstrip('www.') # ── LICENCE AND AUTH ────────────────────────────────────────────────── @api_bp.route('/validate-email', methods=['POST']) @limiter.limit('60 per hour') @require_api_key def validate_email(): """ Validates a PM's access (Apps Script calls this on every sidebar open — see CLAUDE.md §15 Validation.gs cache logic). """ data = request.json or {} email = data.get('email') if not email: return error_response('missing_field', 'email is required', 400) return jsonify(licence_service.validate_email(email)) @api_bp.route('/stripe/webhook', methods=['POST']) def stripe_webhook(): """ Handles Stripe webhook events. Signature-verified instead of X-API-Key — Stripe doesn't send our API key, it signs the payload. """ import stripe payload = request.data sig_header = request.headers.get('Stripe-Signature') try: event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET) except (ValueError, Exception) as e: logger.error(f'Stripe webhook signature verification failed: {str(e)}') return error_response('invalid_signature', 'Webhook signature verification failed', 400) event_type = event['type'] event_data = event['data']['object'] if event_type == 'checkout.session.completed': billing_service.handle_checkout_completed(event_data) elif event_type == 'customer.subscription.updated': tenant = tenant_repository.get_by_stripe_customer_id(event_data.get('customer')) if tenant: billing_service.handle_subscription_updated(event_data, tenant['id']) elif event_type == 'customer.subscription.deleted': tenant = tenant_repository.get_by_stripe_customer_id(event_data.get('customer')) if tenant: billing_service.handle_subscription_deleted(tenant['id']) else: logger.info(f'Unhandled Stripe event type: {event_type}') return jsonify({'status': 'ok'}) @api_bp.route('/auth/request-password-reset', methods=['POST']) @limiter.limit('10 per hour') @require_api_key def request_password_reset(): """Proxies a password reset request to PocketBase's public users collection endpoint.""" data = request.json or {} email = data.get('email') if not email: return error_response('missing_field', 'email is required', 400) try: requests.post( f'{POCKETBASE_URL}/api/collections/users/request-password-reset', json={'email': email}, timeout=10 ) except requests.RequestException as e: logger.error(f'Password reset request failed: {str(e)}') # Never reveal whether the email exists — respond ok regardless. return jsonify({'status': 'ok'}) @api_bp.route('/auth/confirm-password-reset', methods=['POST']) @limiter.limit('10 per hour') @require_api_key def confirm_password_reset(): """Proxies a password reset confirmation to PocketBase.""" data = request.json or {} required = ['token', 'password', 'passwordConfirm'] if not all(data.get(f) for f in required): return error_response('missing_field', 'token, password and passwordConfirm are required', 400) try: response = requests.post( f'{POCKETBASE_URL}/api/collections/users/confirm-password-reset', json=data, timeout=10 ) if response.status_code >= 400: return error_response('reset_failed', 'Reset token is invalid or expired', 400) except requests.RequestException as e: logger.error(f'Password reset confirmation failed: {str(e)}') return error_response('reset_failed', 'Reset token is invalid or expired', 400) return jsonify({'status': 'ok'}) # ── TRIP-INTENT MATCHING + RESEARCH ─────────────────────────────────── @api_bp.route('/research/find-urls', methods=['POST']) @limiter.limit('20 per hour') @require_api_key def find_urls(): """Runs Firecrawl /map concurrently per selected competitor to find matching trip URLs.""" data = request.json or {} required = ['destination', 'duration', 'travel_style', 'competitor_ids'] missing = [f for f in required if f not in data] if missing: return error_response('missing_field', f'Missing fields: {", ".join(missing)}', 400) competitors = [ c for c in (competitor_repository.get_by_id(cid) for cid in data['competitor_ids']) if c ] matches = trip_finder_service.find_all_competitor_urls( competitors, data['destination'], data['duration'], data['travel_style'] ) return jsonify({'matches': matches}) @api_bp.route('/research', methods=['POST']) @limiter.limit('10 per hour') @require_api_key def research(): """Enqueues a research job to RQ — never runs synchronously.""" from jobs import enqueue_research_job data = request.json or {} required = ['tenant_id', 'destination', 'duration', 'travel_style', 'tab_type', 'competitors'] missing = [f for f in required if f not in data] if missing: return error_response('missing_field', f'Missing fields: {", ".join(missing)}', 400) run = scrape_repository.create({ 'tenant_id': data['tenant_id'], 'triggered_by': 'manual', 'status': 'pending', 'competitors_total': len(data['competitors']), 'competitors_done': 0, 'results': {}, }) enqueue_research_job(run['id'], data) return jsonify({'job_id': run['id'], 'status': 'queued'}) @api_bp.route('/research/status/', methods=['GET']) @require_api_key def research_status(job_id): run = scrape_repository.get_by_id(job_id) if not run: return error_response('not_found', 'Unknown job_id', 404) return jsonify({ 'status': run.get('status'), 'progress': { 'total': run.get('competitors_total', 0), 'done': run.get('competitors_done', 0), }, 'results': run.get('results'), 'error': run.get('error_log'), }) @api_bp.route('/research/history', methods=['GET']) @require_api_key def research_history(): email = request.args.get('email') limit = int(request.args.get('limit', 5)) tenant = _resolve_tenant_by_email(email) if not tenant: return error_response('not_found', 'No tenant for this email', 404) return jsonify(scrape_repository.list_recent_for_tenant(tenant['id'], limit=limit)) @api_bp.route('/research/history/latest', methods=['GET']) @require_api_key def research_history_latest(): """ Returns the PM's most recent COMPLETED job — called by the Apps Script sidebar's [Pull latest results] button (CLAUDE.md §15). The Sheet pulls this on demand rather than Flask ever pushing to a Sheet, since a stateless backend has no way to reach into whichever workbook a PM currently has open. """ email = request.args.get('email') tenant = _resolve_tenant_by_email(email) if not tenant: return error_response('not_found', 'No tenant for this email', 404) run = scrape_repository.get_latest_complete_for_tenant(tenant['id']) if not run: return error_response('not_found', 'No completed analysis runs yet', 404) return jsonify(run) @api_bp.route('/research/history/', methods=['GET']) @require_api_key def research_history_detail(job_id): run = scrape_repository.get_by_id(job_id) if not run: return error_response('not_found', 'Unknown job_id', 404) return jsonify(run) @api_bp.route('/preview-prompt', methods=['POST']) @limiter.limit('60 per hour') @require_api_key def preview_prompt(): """Dry-run prompt preview — no scraping. Depends on industries/adventure_travel (Phase 2).""" from industries.adventure_travel.prompt import build_prompt data = request.json or {} placeholder_page = { 'text': '[PAGE CONTENT WILL BE INSERTED HERE]', 'tables': '', 'url': '[COMPETITOR URL]' } prompt = build_prompt( '[Competitor Name]', placeholder_page, data.get('productName', ''), data.get('destination', ''), data.get('duration', ''), data.get('travelStyle', ''), is_ngs=data.get('tabType') == 'NGS' ) return jsonify({'prompt': prompt}) # ── DASHBOARD DATA ───────────────────────────────────────────────────── @api_bp.route('/competitors', methods=['GET']) @require_api_key def get_competitors(): email = request.headers.get('X-User-Email') tenant = _resolve_tenant_by_email(email) if not tenant: return error_response('not_found', 'No tenant for this email', 404) competitors = competitor_repository.list_for_tenant(tenant['id']) return jsonify([ { 'id': c['id'], 'name': c['name'], 'url': c.get('website'), 'primary_market': c.get('primary_market'), 'last_scraped': c.get('last_scraped'), 'change_detected': c.get('change_detected', False), 'status': _competitor_status(c), } for c in competitors ]) @api_bp.route('/battlecards', methods=['POST']) @require_api_key def get_battlecards(): data = request.json or {} tenant = _resolve_tenant_by_email(data.get('email')) if not tenant: return error_response('not_found', 'No tenant for this email', 404) cards = battlecard_repository.list_for_tenant(tenant['id']) results = [] for card in cards: competitor = competitor_repository.get_by_id(card.get('competitor_id')) results.append({ 'competitor_name': competitor.get('name') if competitor else None, 'content': card.get('content'), 'content_json': card.get('content_json'), 'generated_at': card.get('generated_at'), }) return jsonify(results) @api_bp.route('/comparable-trips', methods=['POST']) @require_api_key def get_comparable_trips(): data = request.json or {} tenant = _resolve_tenant_by_email(data.get('email')) if not tenant: return error_response('not_found', 'No tenant for this email', 404) return jsonify(comparable_repository.list_for_tenant(tenant['id'])) @api_bp.route('/alerts', methods=['GET']) @require_api_key def get_alerts(): """ Returns the current session's tenant's recent alerts, for the ActivityPage alerts panel and the sidebar's unread-count badge. unread_only=true narrows to just what still needs attention. """ tenant_id = auth_service.get_tenant_id_from_request(request) if not tenant_id: return error_response('unauthorized', 'Could not resolve tenant from session', 401) unread_only = request.args.get('unread_only', 'false').lower() == 'true' if unread_only: return jsonify(alert_repository.list_unread(tenant_id)) limit = int(request.args.get('limit', 50)) return jsonify(alert_repository.list_recent(tenant_id, limit=limit)) @api_bp.route('/alerts//read', methods=['PATCH']) @require_api_key def mark_alert_read(alert_id): """Marks a single alert as read — called when the PM dismisses/opens it on the dashboard.""" tenant_id = auth_service.get_tenant_id_from_request(request) if not tenant_id: return error_response('unauthorized', 'Could not resolve tenant from session', 401) alert = alert_repository.get_by_id(alert_id) if not alert: return error_response('not_found', 'Unknown alert', 404) if alert.get('tenant_id') != tenant_id: return error_response('forbidden', 'Not authorized for this alert', 403) return jsonify(alert_repository.mark_read(alert_id)) # ── APPS SCRIPT SHEET SYNC ────────────────────────────────────────── # No dedicated routes here per CLAUDE.md §15 — the Sheet-initiated pull # uses GET /research/history/latest (above) and GET # /research/history/ (below). A stateless Flask backend has no # way to reach into whichever Sheet a PM currently has open, so the # Sheet pulls from Flask rather than Flask pushing to the Sheet; # getWorkbookTabs() runs entirely inside the script and needs no Flask # counterpart. # ── DASHBOARD ACCOUNT ────────────────────────────────────────────────── @api_bp.route('/account/me', methods=['GET']) @require_api_key def get_my_account(): """ Resolves the caller's own tenant from their session JWT and returns the same shape as GET /account/ — this is how the dashboard discovers its own tenant_id right after login, since it has no other way to know it before this call. """ tenant_id = auth_service.get_tenant_id_from_request(request) if not tenant_id: return error_response('unauthorized', 'Could not resolve tenant from session', 401) tenant = tenant_repository.get_by_id(tenant_id) seats = seat_repository.list_for_tenant(tenant_id) return jsonify({**tenant, 'seats': seats}) @api_bp.route('/account/', methods=['GET']) @require_api_key def get_account(tenant_id): """ Same payload as GET /account/me, addressed by id. Still requires the caller's own session to resolve to this exact tenant_id — a valid X-API-Key alone must never be enough to read another tenant's account data. """ session_tenant_id = auth_service.get_tenant_id_from_request(request) if session_tenant_id != tenant_id: return error_response('forbidden', 'Not authorized for this tenant', 403) tenant = tenant_repository.get_by_id(tenant_id) if not tenant: return error_response('not_found', 'Unknown tenant', 404) seats = seat_repository.list_for_tenant(tenant_id) return jsonify({**tenant, 'seats': seats}) @api_bp.route('/account/seats', methods=['POST']) @limiter.limit('30 per hour') @require_api_key def add_seat(): data = request.json or {} tenant_id, email = data.get('tenant_id'), data.get('email') if not tenant_id or not email: return error_response('missing_field', 'tenant_id and email are required', 400) tenant = tenant_repository.get_by_id(tenant_id) if not tenant: return error_response('not_found', 'Unknown tenant', 404) if seat_repository.count_active(tenant_id) >= tenant.get('max_seats', 0): return error_response('seat_limit_reached', 'Seat limit reached for this tier', 400) seat = seat_repository.create({ 'tenant_id': tenant_id, 'email': email, 'active': True, 'added_by': 'account_page', }) return jsonify(seat), 201 @api_bp.route('/account/seats/', methods=['DELETE']) @require_api_key def remove_seat(seat_id): seat_repository.delete(seat_id) return jsonify({'status': 'ok'}) @api_bp.route('/account/competitors', methods=['POST']) @limiter.limit('30 per hour') @require_api_key def add_competitor(): data = request.json or {} required = ['tenant_id', 'name', 'website', 'catalogue_url', 'primary_market'] missing = [f for f in required if not data.get(f)] if missing: return error_response('missing_field', f'Missing fields: {", ".join(missing)}', 400) competitor = competitor_repository.create({ 'tenant_id': data['tenant_id'], 'name': data['name'], 'website': data['website'], 'catalogue_url': data['catalogue_url'], 'primary_market': data['primary_market'].upper(), 'active': True, }) _register_monitor(competitor['id'], data['catalogue_url']) return jsonify(competitor), 201 @api_bp.route('/account/competitors/template', methods=['GET']) @require_api_key def download_competitors_template(): csv_content = ( 'name,website,catalogue_url,primary_market\n' '# Example: Intrepid Travel,https://intrepidtravel.com,' 'https://intrepidtravel.com/adventures,GB\n' '# Example: Flash Pack,https://flashpack.com,' 'https://flashpack.com/adventures,US\n' ) return Response( csv_content, mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=bettersight_competitors_template.csv'} ) @api_bp.route('/account/competitors/bulk', methods=['POST']) @limiter.limit('5 per hour') @require_api_key def bulk_import_competitors(): data = request.json or {} tenant_id = data.get('tenant_id') rows = data.get('rows', []) if not tenant_id: return error_response('missing_field', 'tenant_id is required', 400) if len(rows) > 50: return error_response('too_many_rows', 'Maximum 50 competitors per upload.', 400) created, skipped, errors = [], [], [] for row in rows: try: domain = _extract_domain(row['website']) if competitor_repository.get_by_domain(tenant_id, domain): skipped.append(row['name']) continue competitor = competitor_repository.create({ 'tenant_id': tenant_id, 'name': row['name'].strip(), 'website': row['website'].strip(), 'catalogue_url': row['catalogue_url'].strip(), 'primary_market': row['primary_market'].upper().strip(), 'active': True, }) _register_monitor(competitor['id'], row['catalogue_url']) created.append(row['name']) except Exception as e: errors.append({'name': row.get('name', 'Unknown'), 'error': str(e)}) return jsonify({'created': len(created), 'skipped': len(skipped), 'errors': errors}) @api_bp.route('/account/competitors/', methods=['PUT']) @require_api_key def update_competitor(competitor_id): data = request.json or {} updated = competitor_repository.update(competitor_id, data) if not updated: return error_response('not_found', 'Unknown competitor', 404) return jsonify(updated) @api_bp.route('/account/competitors/', methods=['DELETE']) @require_api_key def delete_competitor(competitor_id): competitor = competitor_repository.get_by_id(competitor_id) if not competitor: return error_response('not_found', 'Unknown competitor', 404) competitor_repository.update(competitor_id, {'active': False}) _deregister_monitor(competitor.get('firecrawl_monitor_id')) return jsonify({'status': 'ok'}) @api_bp.route('/account/template', methods=['GET']) @require_api_key def account_template(): """ Returns the hosted Bettersight template Sheet's share URL, per CLAUDE.md §15 Template Import — the PM opens this and copies its one tab into their existing workbook via Google Sheets' "Copy to → Existing spreadsheet", never migrating their own data. """ return jsonify({'template_url': TEMPLATE_SHEET_URL}) @api_bp.route('/account/onboarding', methods=['PATCH']) @require_api_key def update_onboarding(): """ Merges partial onboarding_checklist updates. Two callers, two resolution paths: the dashboard has a PocketBase JWT session (resolved via auth_service); the Apps Script has no JWT at all — only the PM's Google email (Session.getActiveUser()) — so it passes `email` in the body instead, resolved the same way /validate-email resolves a tenant. Either is sufficient; JWT is tried first since it is the stronger signal. """ updates = request.json or {} tenant_id = auth_service.get_tenant_id_from_request(request) if not tenant_id: tenant = _resolve_tenant_by_email(updates.pop('email', None)) tenant_id = tenant['id'] if tenant else None else: updates.pop('email', None) if not tenant_id: return error_response('unauthorized', 'Could not resolve tenant from session or email', 401) checklist = onboarding_service.update_onboarding(tenant_id, updates) return jsonify({'onboarding_checklist': checklist}) @api_bp.route('/account/delete', methods=['POST']) @limiter.limit('3 per day') @require_api_key def delete_account(): """ GDPR Article 17 deletion. Cancels Stripe first, then purges every tenant-scoped collection. Audit logs (digest_logs) are append-only and are intentionally NOT deleted here. """ data = request.json or {} tenant_id = data.get('tenant_id') if not tenant_id: return error_response('missing_field', 'tenant_id is required', 400) tenant = tenant_repository.get_by_id(tenant_id) if not tenant: return error_response('not_found', 'Unknown tenant', 404) if tenant.get('stripe_subscription_id'): try: stripe = billing_service._stripe() stripe.Subscription.delete(tenant['stripe_subscription_id']) except Exception as e: logger.error(f'Stripe cancellation failed during account deletion: {str(e)}') for seat in seat_repository.list_for_tenant(tenant_id): seat_repository.delete(seat['id']) for competitor in competitor_repository.list_for_tenant(tenant_id, active_only=False): competitor_repository.update(competitor['id'], {'active': False}) tenant_repository.update(tenant_id, {'status': 'inactive'}) return jsonify({'status': 'ok'}) @api_bp.route('/billing/portal', methods=['GET']) @require_api_key def billing_portal(): tenant_id = auth_service.get_tenant_id_from_request(request) if not tenant_id: return error_response('unauthorized', 'Could not resolve tenant from session', 401) try: url = billing_service.create_portal_session(tenant_id) except ValueError as e: return error_response('no_stripe_customer', str(e), 400) return jsonify({'url': url}) @api_bp.route('/billing/upgrade', methods=['POST']) @limiter.limit('10 per hour') @require_api_key def billing_upgrade(): data = request.json or {} tenant_id, target_tier = data.get('tenant_id'), data.get('target_tier') if not tenant_id or not target_tier: return error_response('missing_field', 'tenant_id and target_tier are required', 400) try: url = billing_service.create_upgrade_checkout(tenant_id, target_tier) except ValueError as e: return error_response('upgrade_failed', str(e), 400) return jsonify({'checkout_url': url}) # ── INTERNAL (n8n) ────────────────────────────────────────────────── # Exempt from rate limiting (called by n8n only, protected by # X-API-Key) per CLAUDE.md §22 Flask-Limiter Strict Rules. @api_bp.route('/internal/scrape/', methods=['POST']) @require_api_key def internal_scrape(tenant_id): """Triggered by the daily n8n cron for changed/stale competitors only.""" from jobs import enqueue_research_job data = request.json or {} competitors = data.get('competitors', []) run = scrape_repository.create({ 'tenant_id': tenant_id, 'triggered_by': 'cron', 'status': 'pending', 'competitors_total': len(competitors), 'competitors_done': 0, 'results': {}, }) enqueue_research_job(run['id'], {'tenant_id': tenant_id, 'competitors': competitors}) return jsonify({'job_id': run['id'], 'status': 'queued'}) @api_bp.route('/internal/battlecard/', methods=['POST']) @require_api_key def internal_battlecard(competitor_id): data = request.json or {} tenant_id = data.get('tenant_id') if not tenant_id: return error_response('missing_field', 'tenant_id is required', 400) card = battlecard_service.generate_battlecard(competitor_id, tenant_id) return jsonify(card or {'status': 'failed'}) @api_bp.route('/internal/embed-products/', methods=['POST']) @require_api_key def internal_embed_products(tenant_id): """Phase 6 — embeds any of a tenant's products missing an embedding vector.""" from services import embedding_service from repositories.product_repository import product_repository as products_repo embedded = 0 for product in products_repo.list_for_tenant(tenant_id): if product.get('embedding'): continue try: vector = embedding_service.embed_trip(product) products_repo.update(product['id'], {'embedding': vector}) embedded += 1 except Exception as e: logger.error(f'Embedding failed for product {product["id"]}: {str(e)}') return jsonify({'embedded': embedded}) @api_bp.route('/internal/match-comparable/', methods=['POST']) @require_api_key def internal_match_comparable(tenant_id): """Phase 6 — runs semantic similarity matching for a tenant's client trips vs competitor products.""" from services import embedding_service from repositories.product_repository import product_repository as products_repo client_trips = request.json.get('client_trips', []) if request.json else [] competitor_products = products_repo.list_for_tenant(tenant_id) matches = embedding_service.find_comparable_trips(client_trips, competitor_products) created = [ comparable_repository.create({'tenant_id': tenant_id, **match}) for match in matches ] return jsonify({'matches_created': len(created)}) @api_bp.route('/internal/weekly-brief/', methods=['POST']) @require_api_key def internal_weekly_brief(tenant_id): brief_service.send_weekly_brief(tenant_id) return jsonify({'status': 'ok'}) @api_bp.route('/internal/welcome-email/', 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/', 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/', 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(): """Receives Firecrawl Monitor webhook on competitor page change.""" from datetime import datetime, timezone data = request.json or {} url = data.get('url') competitor = competitor_repository.get_by_url(url) if url else None if competitor: competitor_repository.update(competitor['id'], { 'change_detected': True, 'change_detected_at': datetime.now(timezone.utc).isoformat(), }) return jsonify({'status': 'ok'}) @api_bp.route('/internal/monitor/register/', methods=['POST']) @require_api_key def internal_monitor_register(competitor_id): competitor = competitor_repository.get_by_id(competitor_id) if not competitor: return error_response('not_found', 'Unknown competitor', 404) _register_monitor(competitor_id, competitor.get('catalogue_url') or competitor.get('website')) return jsonify({'status': 'ok'}) @api_bp.route('/internal/monitor/deregister/', methods=['POST']) @require_api_key def internal_monitor_deregister(competitor_id): competitor = competitor_repository.get_by_id(competitor_id) if not competitor: return error_response('not_found', 'Unknown competitor', 404) _deregister_monitor(competitor.get('firecrawl_monitor_id')) return jsonify({'status': 'ok'}) # ── SYSTEM ────────────────────────────────────────────────────────── @api_bp.route('/health', methods=['GET']) def health(): return jsonify({'status': 'ok', 'version': '0.1.0-phase1'}) @api_bp.errorhandler(429) def rate_limit_exceeded(e): return error_response('rate_limit_exceeded', 'Too many requests. Please wait before trying again.', 429)