import os import logging import time from datetime import datetime, timezone from functools import wraps from urllib.parse import urlparse from concurrent.futures import ThreadPoolExecutor 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 repositories.watchlist_repository import watchlist_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 from services import alert_service from services import watchlist_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') 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 _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, and — when the caller supplies tenant_id and that tenant has a website on file — also finds the tenant's own matching trip page the same way (client self-scrape feature). tenant_id is optional and not in `required`; omitting it (or a tenant with no website configured) simply omits `own_trip` from the response, which the frontend treats as "not available for this tenant." The own-trip lookup is kicked off in its own thread BEFORE the competitor batch, then joined after — so it overlaps with the competitor ThreadPoolExecutor instead of adding its own /map call's latency on top of the whole competitor batch sequentially. """ 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 ] own_trip_future = None own_trip_executor = None tenant_id = data.get('tenant_id') if tenant_id: tenant = tenant_repository.get_by_id(tenant_id) website = (tenant or {}).get('website') if website: own_trip_executor = ThreadPoolExecutor(max_workers=1) own_trip_future = own_trip_executor.submit( trip_finder_service.find_own_trip_url, website, data['destination'], data['duration'], data['travel_style'] ) matches = trip_finder_service.find_all_competitor_urls( competitors, data['destination'], data['duration'], data['travel_style'] ) own_trip = None if own_trip_future: own_trip = own_trip_future.result() own_trip_executor.shutdown() return jsonify({'matches': matches, 'own_trip': own_trip}) @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 from services import embedding_service 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) # Persists the PM's trip-intent as a client_trips record — the only # place this collection ever gets populated. Without it, Phase 6's # comparable-trip matching has nothing on the client side to embed # or compare against. A save failure is a side effect — never # blocks the primary research job from queuing (CLAUDE.md §18). try: embedding_service.save_client_trip( data['tenant_id'], data['destination'], data['duration'], data['travel_style'], product_name=data.get('productName') or data.get('product_name'), ) except Exception as e: logger.error(f'save_client_trip failed for tenant {data["tenant_id"]}: {str(e)}') # Note: data may also carry 'force_refresh' (TripIntentForm.vue's # collapsed cache-bypass toggle) — deliberately NOT persisted here. # Unlike tab_type (needed later to reconstruct row layout on history # restore), force_refresh is a one-time execution-time override with # no restore/display use case; it still reaches jobs.py via the raw # `data` dict passed to enqueue_research_job() below. run = scrape_repository.create({ 'tenant_id': data['tenant_id'], 'triggered_by': 'manual', 'status': 'pending', 'tab_type': data['tab_type'], # Whichever seat (PM) submitted this run, for RunHistory.vue's # display — sourced from the dashboard's auth session, not looked # up later (a seat could be removed after the run). 'created_by_email': data.get('email'), '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']) # override_defaults=True replaces (not adds to) the blanket 200/hour # default — a polling endpoint needs a rate suited to its actual call # pattern. analysis_store.js polls every 3s for up to 20 minutes # (POLL_TIMEOUT_ATTEMPTS=400), so the 200/hour default alone was # tripping a 429 partway through a single job's polling lifecycle — # discovered when a real test job's status checks started failing with # "ratelimit 200 per 1 hour exceeded at endpoint: api.research_status". # 3000/hour = 50/minute sustained, comfortably covering one job's worst # case (20/minute) plus several concurrent jobs/tabs, while still # bounding a genuinely runaway script far below anything meaningful for # this cheap, read-only lookup. @limiter.limit('3000 per hour', override_defaults=True) @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) if run.get('status') in ('running', 'cancelling'): run = _reconcile_orphaned_job(job_id, run) return jsonify({ 'status': run.get('status'), 'tab_type': run.get('tab_type'), 'progress': { 'total': run.get('competitors_total', 0), 'done': run.get('competitors_done', 0), }, 'results': run.get('results'), 'error': run.get('error_log'), }) def _reconcile_orphaned_job(job_id, run): """ Self-heals a scrape_runs row stuck at status='running' (or the PM having requested cancellation, cancel_requested=True, while it's still nominally 'running') because the RQ worker process actually handling it died outright — e.g. `docker compose up -d --build bettersight-worker` recreating the container mid-job — before run_research_job()'s own try/except/finally ever got to write a terminal status. Cancellation is cooperative (jobs.cancel_research_job()'s docstring): a running job only notices cancel_requested at its next between-competitor checkpoint, which assumes the SAME process stays alive to reach it. A hard-killed worker never reaches that checkpoint again, so without this check the dashboard polls a job that will never change state, showing "Stopping…" (or a stuck progress bar) forever. Checked lazily here, on the exact endpoint the dashboard already polls every 3s (analysis_store.js), rather than a separate background sweep. Data flow: job_id → fetch the underlying RQ Job from Redis → RQ status is 'started'/'queued'/'deferred'/'scheduled' → still genuinely in flight → run returned unchanged → RQ status is 'failed', 'finished', or the job record is missing entirely (NoSuchJobError — expired from Redis, or the worker was killed before RQ even wrote a heartbeat) → this job will never update scrape_runs itself anymore → mark it 'cancelled' (cancel_requested was set) or 'failed' (otherwise) now → updated run record returned """ import redis from jobs import redis_conn from rq.job import Job from rq.exceptions import NoSuchJobError try: rq_status = Job.fetch(job_id, connection=redis_conn).get_status() except NoSuchJobError: rq_status = None except redis.exceptions.RedisError as e: # Can't reach Redis to check — don't guess. Leave the row alone # and let a later poll (once Redis is reachable again) decide, # rather than risk marking a genuinely still-running job failed. logger.warning(f'Could not reach Redis to reconcile job {job_id}: {str(e)}') return run if rq_status in ('started', 'queued', 'deferred', 'scheduled'): return run # genuinely still in flight — nothing to reconcile new_status = 'cancelled' if run.get('cancel_requested') else 'failed' logger.warning( f'Job {job_id} orphaned (RQ status: {rq_status}) — reconciling scrape_runs to "{new_status}"' ) return scrape_repository.update(job_id, { 'status': new_status, 'error_log': run.get('error_log') or 'Job was interrupted before it could finish (worker restarted or crashed).', 'completed_at': datetime.now(timezone.utc).isoformat(), }) @api_bp.route('/research/cancel/', methods=['POST']) @limiter.limit('20 per hour') @require_api_key def research_cancel(job_id): """PM-initiated stop — see jobs.cancel_research_job() for why this is distinct from disconnect-survival.""" from jobs import cancel_research_job if not cancel_research_job(job_id): return error_response('not_found', 'Unknown job_id', 404) return jsonify({'status': 'cancelling'}) @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', ''), company_name=data.get('companyName'), 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)) # ── TRIP WATCHLIST ─────────────────────────────────────────────────── # Per-seat, tier-capped list of specific trips the nightly n8n cron # re-scrapes instead of each competitor's root website — see # services/watchlist_service.py's WATCHLIST_CAP_PER_SEAT and # n8n/workflows/daily_scrape_cron.json. @api_bp.route('/watchlist', methods=['GET']) @require_api_key def get_watchlist(): """ Returns every watched item for the session's tenant (every seat's, not just the caller's) — the cap is per-seat, but visibility is tenant-wide so teammates can see what each other is tracking. """ 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) return jsonify(watchlist_service.list_watchlist_for_tenant(tenant_id)) @api_bp.route('/watchlist', methods=['POST']) @limiter.limit('30 per hour') @require_api_key def add_watchlist_item(): """ Pins a specific competitor trip (from a completed analysis result) to the calling seat's personal watchlist. 400 with a clear message if that seat is already at WATCHLIST_CAP_PER_SEAT for its tier. """ tenant_id, seat = auth_service.get_seat_from_request(request) if not tenant_id or not seat: return error_response('unauthorized', 'Could not resolve seat from session', 401) data = request.json or {} required = ['competitor_id', 'url'] 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.get_by_id(data['competitor_id']) if not competitor or competitor.get('tenant_id') != tenant_id: return error_response('not_found', 'Unknown competitor', 404) tenant = tenant_repository.get_by_id(tenant_id) result = watchlist_service.add_to_watchlist( tenant, seat, data['competitor_id'], data['url'], data.get('trip_name') ) if not result['success']: return error_response('watchlist_full', result['reason'], 400) return jsonify(result['item']), 201 @api_bp.route('/watchlist/', methods=['DELETE']) @limiter.limit('30 per hour') @require_api_key def remove_watchlist_item(item_id): """Removes a watched trip — any active seat on the tenant may remove any item.""" 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) item = watchlist_repository.get_by_id(item_id) if not item or item.get('tenant_id') != tenant_id: return error_response('not_found', 'Unknown watchlist item', 404) watchlist_service.remove_from_watchlist(item_id) return jsonify({'status': 'ok'}) # ── 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, }) 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, }) 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}) 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/timezone', methods=['PATCH']) @require_api_key def update_timezone(): """ Captures the PM's browser timezone on first login when tenants.timezone is still empty (CLAUDE.md §19 Phase 7 item 61). There's no signup form in this dashboard's scope — account creation happens via Stripe webhook or the manual onboarding runbook (§32) — so first login is the earliest point browser JS can run to capture Intl.DateTimeFormat().resolvedOptions().timeZone. Never overwrites a timezone that's already set. """ 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) if tenant.get('timezone'): return jsonify({'timezone': tenant['timezone']}) timezone = (request.json or {}).get('timezone') if not timezone: return error_response('missing_field', 'timezone is required', 400) updated = tenant_repository.update(tenant_id, {'timezone': timezone}) return jsonify({'timezone': updated['timezone']}) @api_bp.route('/account/website', methods=['PATCH']) @require_api_key def update_website(): """ Sets or changes the tenant's own storefront URL — used by the client self-scrape feature to find/scrape the tenant's own matching trip page the same way competitor pages are matched/scraped. Unlike /account/timezone, this is freely editable at any time (a PM may reasonably correct or change their site later), not first-write-wins. """ 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) website = (request.json or {}).get('website') if not website: return error_response('missing_field', 'website is required', 400) updated = tenant_repository.update(tenant_id, {'website': website}) return jsonify({'website': updated['website']}) @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. See billing_service.delete_tenant_data() for the full data flow — cancels Stripe, purges every tenant-scoped collection including the tenant record itself, and sends a deletion confirmation email. digest_logs is the one collection intentionally left untouched (CLAUDE.md's own delete list doesn't include it, and it holds no PII — just send timestamps/status). """ data = request.json or {} tenant_id = data.get('tenant_id') if not tenant_id: return error_response('missing_field', 'tenant_id is required', 400) deleted = billing_service.delete_tenant_data(tenant_id) if not deleted: return error_response('not_found', 'Unknown tenant', 404) 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 competitor products AND client trips missing an embedding vector. Both sides need a vector before /internal/match-comparable can compare them — this runs first in the Sunday-night n8n workflow. """ from services import embedding_service from repositories.product_repository import product_repository as products_repo from repositories.client_trips_repository import client_trips_repository embedded_products = 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_products += 1 except Exception as e: logger.error(f'Embedding failed for product {product["id"]}: {str(e)}') embedded_client_trips = 0 for trip in client_trips_repository.list_for_tenant(tenant_id): if trip.get('embedding'): continue try: vector = embedding_service.embed_trip(trip) client_trips_repository.update(trip['id'], {'embedding': vector}) embedded_client_trips += 1 except Exception as e: logger.error(f'Embedding failed for client trip {trip["id"]}: {str(e)}') return jsonify({ 'embedded': embedded_products + embedded_client_trips, 'embedded_products': embedded_products, 'embedded_client_trips': embedded_client_trips, }) @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. Reads both sides from PocketBase (via their repositories) rather than expecting client_trips in the request body, so this route is callable unattended by n8n — the same pattern as every other /internal/* route in this file. """ from datetime import datetime, timezone from services import embedding_service from repositories.product_repository import product_repository as products_repo from repositories.client_trips_repository import client_trips_repository client_trips = client_trips_repository.list_for_tenant(tenant_id) competitor_products = products_repo.list_for_tenant(tenant_id) matches = embedding_service.find_comparable_trips(client_trips, competitor_products) # Upserts on (client_product, competitor_product) so a repeat weekly # match updates last_matched/scores in place instead of duplicating # a row every Sunday — first_matched/last_matched only make sense # if the same pair is tracked across runs, not recreated each time. # A dismissed match's dismissed flag is deliberately left untouched # on update — the PM's "not relevant" call should survive a re-match. now = datetime.now(timezone.utc).isoformat() created, updated = [], [] for match in matches: existing = comparable_repository.get_by_client_and_competitor_product( tenant_id, match['client_product'], match['competitor_product'] ) if existing: comparable_repository.update(existing['id'], {**match, 'last_matched': now}) updated.append(existing['id']) else: comparable_repository.create({'tenant_id': tenant_id, 'last_matched': now, **match}) created.append(match) return jsonify({'matches_created': len(created), 'matches_updated': len(updated)}) @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/daily-digest/', methods=['POST']) @require_api_key def internal_daily_digest(tenant_id): """Called once per active tenant by the n8n 6pm daily-digest workflow (CLAUDE.md §15).""" sent = alert_service.send_daily_digest(tenant_id) return jsonify({'sent': sent}) @api_bp.route('/internal/data-cleanup', methods=['POST']) @require_api_key def internal_data_cleanup(): """ Called once (not per-tenant — this is a global sweep) by the n8n Sunday 3am weekly cleanup workflow (CLAUDE.md §22 DATA_RETENTION). """ from services import retention_service result = retention_service.run_weekly_cleanup() return jsonify(result) @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}) # ── 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)