950 lines
38 KiB
Python
950 lines
38 KiB
Python
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
|
|
from services import alert_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."""
|
|
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
|
|
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/<job_id>', 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)
|
|
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'),
|
|
})
|
|
|
|
|
|
@api_bp.route('/research/cancel/<job_id>', 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/<job_id>', 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/<alert_id>/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/<job_id> (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/<tenant_id> — 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/<tenant_id>', 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/<seat_id>', 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/<competitor_id>', 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/<competitor_id>', 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/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/<tenant_id>', 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/<competitor_id>', 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/<tenant_id>', 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/<tenant_id>', 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/<tenant_id>', 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/<tenant_id>', 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/<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})
|
|
|
|
|
|
# ── 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)
|