app.bettersight.io/backend/api.py

763 lines
29 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 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
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')
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/<job_id>', 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/<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', ''),
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']))
# ── APPS SCRIPT EXPORT INTEGRATION ────────────────────────────────────
# NOTE: the Apps Script is a standalone script running in the PM's own
# Google account (CLAUDE.md §15) — it is not deployed or addressable by
# this backend until Phase 4 defines its Web App URL / install model.
# These two routes have a correct, working contract against PocketBase
# today; the actual "push the bytes into the PM's open Sheet" step is a
# Phase 4 dependency and is clearly marked below rather than guessed at.
@api_bp.route('/sheet/push', methods=['POST'])
@require_api_key
def sheet_push():
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)
run = scrape_repository.get_by_id(data.get('job_id', ''))
if not run:
return error_response('not_found', 'Unknown job_id', 404)
# Phase 4 TODO: invoke the PM's installed Apps Script (via its Web
# App URL, once that deployment model is built) with run['results']
# and data['tab_name']. Until then this confirms the data exists and
# is ready to push, without a transport to actually deliver it.
return error_response(
'not_yet_available',
'Push to Sheet requires the Apps Script plugin (Phase 4) — not yet deployed.',
501
)
@api_bp.route('/sheet/tabs', methods=['GET'])
@require_api_key
def sheet_tabs():
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)
# Phase 4 TODO: same dependency as /sheet/push above.
return error_response(
'not_yet_available',
'Sheet tab listing requires the Apps Script plugin (Phase 4) — not yet deployed.',
501
)
# ── DASHBOARD ACCOUNT ──────────────────────────────────────────────────
@api_bp.route('/account/<tenant_id>', methods=['GET'])
@require_api_key
def get_account(tenant_id):
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,
})
_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/<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})
_deregister_monitor(competitor.get('firecrawl_monitor_id'))
return jsonify({'status': 'ok'})
@api_bp.route('/account/template', methods=['GET'])
@require_api_key
def account_template():
# Sheet template is a static Google Sheets link hosted outside this
# backend — returned as a redirect target for the frontend to open.
return jsonify({'template_url': 'https://docs.google.com/spreadsheets/d/TEMPLATE_SHEET_ID/copy'})
@api_bp.route('/account/onboarding', methods=['PATCH'])
@require_api_key
def update_onboarding():
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)
updates = request.json or {}
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/<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 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/<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."""
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/<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/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/<competitor_id>', 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/<competitor_id>', 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)