"""Owner endpoints (X-CA-Owner-Token): watchlist and alerts. The owner row is created on first use; nothing here is cached.""" from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, Field from companyatlas.api import queries as q from companyatlas.api import serializers as ser from companyatlas.api.common import NO_STORE, owner_hash from companyatlas.db import execute, fetch_all, fetch_one, fetch_val, jsonb, transaction from companyatlas.ids import new_id from companyatlas.taxonomy import EventType ORDER = 20 router = APIRouter(prefix="/api/v1", tags=["owner"]) MAX_WATCHLIST = 200 MAX_ALERTS = 100 KNOWN_TYPES = {t.value for t in EventType} async def _ensure_owner(conn: Any, oh: str) -> str: await execute(conn, "insert into owners (token_hash) values (:h) on conflict (token_hash) do update set last_seen_at = now()", h=oh) wl = await fetch_val(conn, "select id from watchlists where owner_hash = :h order by created_at limit 1", h=oh) if wl is None: wl = new_id("watchlist") await execute(conn, "insert into watchlists (id, owner_hash) values (:id, :h)", id=wl, h=oh) return wl class WatchBody(BaseModel): company: str = Field(min_length=1, max_length=200) @router.get("/watchlist", summary="Watched companies and their recent events") async def get_watchlist(response: Response, oh: str = Depends(owner_hash), events_limit: int = Query(30, ge=1, le=100)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: wl = await _ensure_owner(conn, oh) rows = await fetch_all(conn, "select company_id, added_at from watchlist_items where watchlist_id = :wl order by added_at desc limit :lim", wl=wl, lim=MAX_WATCHLIST) ids = [r["company_id"] for r in rows] cards = [ser.company_card(c) for c in await q.fetch_cards_by_ids(conn, ids, sparkline=True)] events: list[dict[str, Any]] = [] if ids: where, params = q.event_filters() where.append("e.company_id = any(cast(:wl_ids as text[]))") params["wl_ids"] = ids events = [ser.event(r) for r in await q.fetch_events(conn, where, params, limit=events_limit)] added = {r["company_id"]: r["added_at"] for r in rows} for c in cards: c["added_at"] = added.get(c["id"]) return {"id": wl, "items": cards, "events": events, "count": len(cards), "max": MAX_WATCHLIST} @router.post("/watchlist", status_code=201, summary="Add a company to the watchlist") async def add_to_watchlist(body: WatchBody, response: Response, oh: str = Depends(owner_hash)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: wl = await _ensure_owner(conn, oh) c = await q.require_company(conn, body.company) n = await fetch_val(conn, "select count(*) from watchlist_items where watchlist_id = :wl", wl=wl) exists = await fetch_val(conn, "select 1 from watchlist_items where watchlist_id = :wl and company_id = :cid", wl=wl, cid=c["id"]) if not exists and int(n or 0) >= MAX_WATCHLIST: raise HTTPException(status_code=409, detail=f"watchlist is full ({MAX_WATCHLIST})") await execute(conn, "insert into watchlist_items (watchlist_id, company_id) values (:wl, :cid) on conflict do nothing", wl=wl, cid=c["id"]) cards = await q.fetch_cards_by_ids(conn, [c["id"]]) return {"added": not bool(exists), "company": ser.company_card(cards[0]) if cards else None} @router.delete("/watchlist/{key}", summary="Remove a company from the watchlist") async def remove_from_watchlist(key: str, response: Response, oh: str = Depends(owner_hash)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: wl = await _ensure_owner(conn, oh) c = await q.require_company(conn, key) removed = await fetch_val(conn, "with d as (delete from watchlist_items where watchlist_id = :wl and company_id = :cid returning 1) select count(*) from d", wl=wl, cid=c["id"]) return {"removed": bool(removed), "company": c["slug"]} class MetricCondition(BaseModel): gt: float | None = None lt: float | None = None class AlertCondition(BaseModel): event_types: list[str] | None = None event_subtypes: list[str] | None = None min_importance: float | None = Field(None, ge=0, le=1) metrics: dict[str, MetricCondition] | None = None industries: list[str] | None = None countries: list[str] | None = None class AlertBody(BaseModel): name: str = Field(min_length=1, max_length=120) company: str | None = Field(None, max_length=200) condition: AlertCondition = Field(default_factory=AlertCondition) channel: str = Field("web", pattern="^(web|webhook)$") target: str | None = Field(None, max_length=500) enabled: bool = True @router.get("/alerts", summary="Alerts of this owner") async def list_alerts(response: Response, oh: str = Depends(owner_hash)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: await _ensure_owner(conn, oh) rows = await fetch_all(conn, "select a.*, c.slug as company_slug, c.display_name as company_display_name, c.canonical_domain as company_domain, " "c.country as company_country, c.logo_url as company_logo_url from alerts a left join companies c on c.id = a.company_id " "where a.owner_hash = :h order by a.created_at desc limit :lim", h=oh, lim=MAX_ALERTS) return {"items": [ser.alert(r) for r in rows], "count": len(rows), "max": MAX_ALERTS} @router.post("/alerts", status_code=201, summary="Create an alert") async def create_alert(body: AlertBody, response: Response, oh: str = Depends(owner_hash)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE cond = body.condition.model_dump(exclude_none=True) unknown = [t for t in (cond.get("event_types") or []) if t.upper() not in KNOWN_TYPES] if unknown: raise HTTPException(status_code=422, detail=f"unknown event_types: {', '.join(unknown[:5])}") if cond.get("event_types"): cond["event_types"] = [t.upper() for t in cond["event_types"]] if body.channel == "webhook" and (not body.target or not body.target.lower().startswith(("https://", "http://"))): raise HTTPException(status_code=422, detail="target: webhook alerts need an http(s) URL") async with transaction() as conn: await _ensure_owner(conn, oh) n = await fetch_val(conn, "select count(*) from alerts where owner_hash = :h", h=oh) if int(n or 0) >= MAX_ALERTS: raise HTTPException(status_code=409, detail=f"too many alerts ({MAX_ALERTS})") company_id = (await q.require_company(conn, body.company))["id"] if body.company else None if company_id is None and not cond: raise HTTPException(status_code=422, detail="an alert needs a company or at least one condition") aid = new_id("alert") await execute(conn, "insert into alerts (id, owner_hash, company_id, name, condition, channel, target, enabled) " "values (:id, :h, :cid, :name, cast(:cond as jsonb), :channel, :target, :enabled)", id=aid, h=oh, cid=company_id, name=body.name.strip(), cond=jsonb(cond), channel=body.channel, target=body.target, enabled=body.enabled) row = await fetch_one(conn, "select a.*, c.slug as company_slug, c.display_name as company_display_name, c.canonical_domain as company_domain, " "c.country as company_country, c.logo_url as company_logo_url from alerts a left join companies c on c.id = a.company_id " "where a.id = :id", id=aid) return ser.alert(row or {}) @router.delete("/alerts/{alert_id}", summary="Delete an alert") async def delete_alert(alert_id: str, response: Response, oh: str = Depends(owner_hash)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: await _ensure_owner(conn, oh) removed = await fetch_val(conn, "with d as (delete from alerts where id = :id and owner_hash = :h returning 1) select count(*) from d", id=alert_id, h=oh) if not removed: raise HTTPException(status_code=404, detail="alert not found") return {"removed": True, "id": alert_id} @router.get("/alerts/deliveries", summary="Recent alert deliveries for this owner") async def alert_deliveries(response: Response, oh: str = Depends(owner_hash), limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: response.headers["cache-control"] = NO_STORE async with transaction() as conn: await _ensure_owner(conn, oh) rows = await fetch_all(conn, "select d.*, a.name as alert_name, e.title as event_title from alert_deliveries d join alerts a on a.id = d.alert_id " "left join events e on e.id = d.event_id where a.owner_hash = :h order by d.delivered_at desc limit :lim", h=oh, lim=limit) return {"items": [ser.alert_delivery(r) for r in rows]}