{"openapi":"3.0.3","info":{"title":"Content Agent Dashboard API","version":"1.9.0","description":"A local Express + Postgres API over the dashboard's tracked social/ad data. Single-operator internal tool — CORS is same-origin-only by default (see `CORS_ALLOWED_ORIGINS`), and an optional `X-API-Key` header is enforced only once a key is set under Settings → \"Dashboard API access\" (same-origin requests are always exempt).\n\nAll routes are also reachable under the `/api/v1/` prefix (a stable alias for `/api/`, added for external integrations to pin to — both paths behave identically and there is currently no `/api/v2`).\n\nSupports optional `?limit=&offset=` pagination — omit both for the full, unpaginated array (the default, unchanged behavior). When `limit` is passed, the response is sliced server-side and the total row count (pre-slice) is returned in the `X-Total-Count` response header."},"servers":[{"url":"http://localhost:8091","description":"Local dev (also reachable at /api/v1/*)"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Only required for cross-origin callers once Settings → \"Dashboard API access\" has a key configured. Same-origin requests never need it."}},"schemas":{"Category":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}},"Account":{"type":"object","properties":{"id":{"type":"integer"},"platform":{"type":"string","example":"facebook"},"handle":{"type":"string"},"profile_url":{"type":"string"},"followers_count":{"type":"integer","nullable":true},"last_synced_posts_at":{"type":"string","format":"date-time","nullable":true},"account_type":{"type":"string","enum":["business","personal"],"nullable":true},"account_type_source":{"type":"string","nullable":true},"transcribe_enabled":{"type":"boolean","nullable":true}}},"Source":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"type":{"type":"string","enum":["business","person"]},"is_own":{"type":"boolean"},"category_id":{"type":"integer","nullable":true},"category_name":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"contact_email":{"type":"string","nullable":true},"contact_phone":{"type":"string","nullable":true},"contact_linkedin_url":{"type":"string","nullable":true},"contact_other":{"type":"string","nullable":true},"contact_looked_up_at":{"type":"string","format":"date-time","nullable":true},"accounts":{"type":"array","items":{"$ref":"#/components/schemas/Account"}}}},"Post":{"type":"object","properties":{"id":{"type":"integer"},"url":{"type":"string"},"type":{"type":"string"},"caption":{"type":"string","nullable":true},"posted_at":{"type":"string","format":"date-time","nullable":true},"views":{"type":"integer","nullable":true},"likes":{"type":"integer","nullable":true},"comments_count":{"type":"integer","nullable":true},"shares":{"type":"integer","nullable":true},"sentiment_score":{"type":"number","nullable":true},"sentiment_label":{"type":"string","nullable":true},"account_handle":{"type":"string"},"platform_id":{"type":"integer"},"platform":{"type":"string"},"source_id":{"type":"integer"},"source_name":{"type":"string"},"source_type":{"type":"string"},"is_own":{"type":"boolean"},"category_id":{"type":"integer","nullable":true},"category_name":{"type":"string","nullable":true},"media":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"media_type":{"type":"string"},"local_path":{"type":"string","nullable":true},"position":{"type":"integer"},"transcript":{"type":"string","nullable":true}}}}}},"Comment":{"type":"object","properties":{"id":{"type":"integer"},"username":{"type":"string"},"full_name":{"type":"string","nullable":true},"is_verified":{"type":"boolean","nullable":true},"is_private":{"type":"boolean","nullable":true},"text":{"type":"string","nullable":true},"likes":{"type":"integer","nullable":true},"replies_count":{"type":"integer","nullable":true},"commented_at":{"type":"string","format":"date-time","nullable":true},"profile_pic_url":{"type":"string","nullable":true},"sentiment_score":{"type":"number","nullable":true},"sentiment_label":{"type":"string","nullable":true},"post_id":{"type":"integer"},"post_url":{"type":"string"},"account_handle":{"type":"string"},"platform_id":{"type":"integer"},"platform":{"type":"string"},"source_id":{"type":"integer"},"source_name":{"type":"string"}}},"CommenterProfile":{"type":"object","description":"Every column on commenter_profiles, plus the joined platform slug (the route selects cp.* so the exact column set tracks the live schema).","properties":{"id":{"type":"integer"},"username":{"type":"string"},"platform":{"type":"string"},"followers_count":{"type":"integer","nullable":true},"bio":{"type":"string","nullable":true},"klout_score":{"type":"number","nullable":true},"industry_relevant":{"type":"boolean","nullable":true},"is_business_account":{"type":"boolean","nullable":true},"review_status":{"type":"string","enum":["unreviewed","added","ignored","skipped"]}}},"Ad":{"type":"object","properties":{"id":{"type":"integer"},"network":{"type":"string","enum":["meta","google","linkedin","tiktok"]},"ad_library_id":{"type":"string"},"ad_type":{"type":"string","enum":["image","video","carousel","collection","text","unknown"]},"ad_copy_text":{"type":"string","nullable":true},"ad_copy_source":{"type":"string","enum":["actor","ocr"],"nullable":true,"description":"'actor' = returned directly by the scraping provider's own structured field. 'ocr' = no such field exists (true for Google); text was instead read off the creative image locally via tesseract.js — may contain errors."},"ad_creative_structured":{"type":"object","nullable":true,"description":"Google only — the OCR text broken into the rendered ad's real visual sections, rather than one flat blob. ad_copy_text is a clean headline+description summary derived from these same fields.","properties":{"advertiserName":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true},"headline":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"sitelinks":{"type":"array","items":{"type":"string"},"description":"Every distinct sitelink label recovered, whether Google rendered them as one hyphen-joined line or several separate stacked rows. Empty array if none were found."}}},"landing_page_url":{"type":"string","nullable":true,"description":"Only a real landing page for network='meta'; for google it's a preview-asset link, for linkedin it's the Ad Library's own detail page."},"primary_image_url":{"type":"string","nullable":true},"primary_video_url":{"type":"string","nullable":true},"media_variations":{"type":"array","items":{"type":"object","properties":{"imageUrl":{"type":"string","nullable":true},"videoUrl":{"type":"string","nullable":true},"copyText":{"type":"string","nullable":true}}},"description":"Every distinct creative variation (e.g. every carousel card), not just the primary one."},"placements":{"type":"array","items":{"type":"string"},"description":"Meta only — publisherPlatform values (FACEBOOK/INSTAGRAM/etc). Empty for other networks."},"collation_id":{"type":"string","nullable":true},"collation_count":{"type":"integer","nullable":true},"is_active":{"type":"boolean"},"first_seen_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time"},"source_id":{"type":"integer"},"source_name":{"type":"string"},"impressions_ranges_seen":{"type":"array","items":{"type":"string"},"nullable":true},"spend_ranges_seen":{"type":"array","items":{"type":"string"},"nullable":true}}},"AdAlert":{"type":"object","properties":{"id":{"type":"integer"},"alert_type":{"type":"string","enum":["new_advertiser","cross_network_reuse"]},"related_ad_ids":{"type":"array","items":{"type":"integer"},"nullable":true},"detected_at":{"type":"string","format":"date-time"},"details":{"type":"object","nullable":true},"acknowledged":{"type":"boolean"},"source_id":{"type":"integer","nullable":true},"source_name":{"type":"string","nullable":true}}},"AdAdvertiserInfo":{"type":"object","description":"Meta only — page-transparency enrichment (isDetailsPerAd/includeAboutPage). Google/LinkedIn have no equivalent dataset.","properties":{"source_id":{"type":"integer"},"source_name":{"type":"string"},"network":{"type":"string"},"about_text":{"type":"string","nullable":true},"page_created_at":{"type":"string","nullable":true},"name_change_count":{"type":"integer","nullable":true},"admin_country_counts":{"type":"array","items":{"type":"object"},"nullable":true},"ig_username":{"type":"string","nullable":true},"ig_followers":{"type":"integer","nullable":true},"fb_page_likes":{"type":"integer","nullable":true},"page_category":{"type":"string","nullable":true},"page_verification":{"type":"string","nullable":true}}},"RuleCondition":{"type":"object","description":"A recursive AND/OR condition tree used by List.rule_conditions. A node is either a leaf ({field, operator, value}) or a group ({op: \"and\"|\"or\", conditions: [node, ...]}). Field vocabulary depends on the parent list's entity_type: for \"ad\" — network/source_name/ad_type/is_active/ad_copy_text/landing_page_url/first_seen_at/last_seen_at; for \"advertiser\" — network/source_name/about_text/page_category/page_verification/ig_followers/fb_page_likes/name_change_count/page_created_at. Operators: equals/not_equals (select or text, case-insensitive), contains/not_contains (text), gt/gte/lt/lte (number), before/after/between (date).","additionalProperties":true,"properties":{"field":{"type":"string","nullable":true,"description":"Present on a leaf node."},"operator":{"type":"string","enum":["equals","not_equals","contains","not_contains","gt","gte","lt","lte","before","after","between"],"nullable":true},"value":{"nullable":true,"description":"A scalar for most operators; {from, to} for \"between\"."},"op":{"type":"string","enum":["and","or"],"nullable":true,"description":"Present on a group node."},"conditions":{"type":"array","items":{"type":"object"},"nullable":true,"description":"Present on a group node — an array of nested RuleCondition nodes."}}},"List":{"type":"object","properties":{"id":{"type":"integer"},"entity_type":{"type":"string","enum":["ad","advertiser"]},"name":{"type":"string"},"description":{"type":"string","nullable":true},"rule_conditions":{"allOf":[{"$ref":"#/components/schemas/RuleCondition"}],"nullable":true,"description":"null = purely manual list."},"manual_count":{"type":"integer","description":"Members with added_via=manual."},"rule_count":{"type":"integer","description":"Members currently matched by the rule (added_via=rule)."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ListMember":{"type":"object","description":"A resolved list member — entity_id/added_via/added_at from list_members, plus every column of the joined Ad or (Source + AdAdvertiserInfo) row depending on the list's entity_type.","properties":{"entity_id":{"type":"integer","description":"ads.id for an \"ad\" list, sources.id for an \"advertiser\" list."},"added_via":{"type":"string","enum":["manual","rule"]},"added_at":{"type":"string","format":"date-time"}},"additionalProperties":true},"Tag":{"type":"object","properties":{"id":{"type":"integer"},"entity_type":{"type":"string","enum":["ad","advertiser"]},"name":{"type":"string"},"entity_count":{"type":"integer","description":"How many ads/advertisers currently carry this tag."},"created_at":{"type":"string","format":"date-time"}}},"AdLibraryProject":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"item_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Platform":{"type":"object","properties":{"id":{"type":"integer"},"slug":{"type":"string"},"name":{"type":"string"}}},"Error":{"type":"object","properties":{"error":{"type":"string"}}},"Ok":{"type":"object","properties":{"ok":{"type":"boolean"}}}}},"security":[{"ApiKeyAuth":[]}],"paths":{"/api/categories":{"get":{"summary":"List categories","tags":["Categories"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Category"}}}}}}},"post":{"summary":"Create a category","tags":["Categories"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Category"}}}}}}},"/api/categories/{id}":{"put":{"summary":"Rename a category","tags":["Categories"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Category"}}}}}},"delete":{"summary":"Delete a category","tags":["Categories"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/sources":{"get":{"summary":"List businesses & people, each with their tracked platform accounts","tags":["Sources"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Source"}}}}}}},"post":{"summary":"Create a source, optionally with its first platform account","tags":["Sources"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","type"],"properties":{"name":{"type":"string"},"type":{"type":"string","enum":["business","person"]},"is_own":{"type":"boolean"},"category_id":{"type":"integer","nullable":true},"notes":{"type":"string","nullable":true},"handle":{"type":"string","nullable":true},"profile_url":{"type":"string","nullable":true},"platform_slug":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Source"}}}}}}},"/api/sources/{id}":{"put":{"summary":"Update a source","tags":["Sources"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Source"}}}}}},"delete":{"summary":"Delete a source (cascades to its accounts)","tags":["Sources"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/accounts":{"post":{"summary":"Attach a platform account to an existing source","tags":["Accounts"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["source_id","platform_slug","handle"],"properties":{"source_id":{"type":"integer"},"platform_slug":{"type":"string"},"handle":{"type":"string"},"profile_url":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Created/updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}}}},"/api/accounts/{id}":{"delete":{"summary":"Remove a platform account","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/accounts/{id}/account-type":{"put":{"summary":"Set (or clear) a manual business/personal override for an account","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"account_type":{"type":"string","enum":["business","personal"],"nullable":true,"description":"null clears the manual override"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}}}},"/api/accounts/{id}/transcribe-enabled":{"put":{"summary":"Set (or clear) a per-account transcription override","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"value":{"type":"boolean","nullable":true,"description":"null inherits the platform default"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}}}}},"/api/commenter-profiles":{"get":{"summary":"List commenter profiles (Klout scoring input)","tags":["Commenters"],"description":"Supports optional `?limit=&offset=` pagination — omit both for the full, unpaginated array (the default, unchanged behavior). When `limit` is passed, the response is sliced server-side and the total row count (pre-slice) is returned in the `X-Total-Count` response header.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Max rows to return. Omit for the full array (default, backward-compatible)."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip before applying limit."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CommenterProfile"}}}}}}}},"/api/commenter-profiles/{id}":{"put":{"summary":"Manually set industry-relevance / business-account flags","tags":["Commenters"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommenterProfile"}}}}}}},"/api/commenter-profiles/{id}/review":{"put":{"summary":"Set a commenter profile's review status","tags":["Commenters"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["unreviewed","added","ignored","skipped"]}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommenterProfile"}}}}}}},"/api/commenter-profiles/{id}/promote":{"post":{"summary":"Promote a commenter into a tracked source","tags":["Commenters"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"source_id":{"type":"integer"}}}}}}}}},"/api/commenter-profiles/bulk-promote":{"post":{"summary":"Promote multiple commenters into tracked sources at once","tags":["Commenters"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"integer"}},"category_id":{"type":"integer","nullable":true},"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"promoted":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"source_id":{"type":"integer"}}}}}}}}}}}},"/api/commenter-profiles/{id}/collect-details":{"post":{"summary":"Fetch fresh profile details (bio, follower count) for a commenter via Apify","tags":["Commenters"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommenterProfile"}}}}}}},"/api/person-employment":{"get":{"summary":"List person↔business employment history rows","tags":["Employment"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}},"post":{"summary":"Add an employment history row","tags":["Employment"],"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/person-employment/{id}":{"put":{"summary":"Update an employment history row","tags":["Employment"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object"}}}}}},"delete":{"summary":"Delete an employment history row","tags":["Employment"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/account-snapshots":{"get":{"summary":"List time-series follower/post-count snapshots","tags":["Analytics"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/keywords":{"get":{"summary":"List extracted keywords (word-cloud / trend-over-time source data)","tags":["Analytics"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/ads":{"get":{"summary":"List tracked competitor ads across all networks","tags":["Ad Monitoring"],"description":"Supports optional `?limit=&offset=` pagination — omit both for the full, unpaginated array (the default, unchanged behavior). When `limit` is passed, the response is sliced server-side and the total row count (pre-slice) is returned in the `X-Total-Count` response header.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Max rows to return. Omit for the full array (default, backward-compatible)."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip before applying limit."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Ad"}}}}}}}},"/api/ad-alerts":{"get":{"summary":"List new-advertiser / cross-network-reuse alerts","tags":["Ad Monitoring"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdAlert"}}}}}}}},"/api/ad-alerts/{id}/acknowledge":{"put":{"summary":"Acknowledge an alert (one-way — no un-acknowledge)","tags":["Ad Monitoring"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/ad-advertiser-info":{"get":{"summary":"List Meta page-transparency enrichment data per source","tags":["Ad Monitoring"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdAdvertiserInfo"}}}}}}}},"/api/lists":{"get":{"summary":"List Lists (optionally filtered by entity_type)","tags":["Lists & Rules"],"parameters":[{"name":"entity_type","in":"query","schema":{"type":"string","enum":["ad","advertiser"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/List"}}}}}}},"post":{"summary":"Create a List, optionally with a rule (auto-evaluated once on creation)","tags":["Lists & Rules"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["entity_type","name"],"properties":{"entity_type":{"type":"string","enum":["ad","advertiser"]},"name":{"type":"string"},"description":{"type":"string","nullable":true},"rule_conditions":{"allOf":[{"$ref":"#/components/schemas/RuleCondition"}],"nullable":true}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}}}}},"/api/lists/{id}":{"put":{"summary":"Update a List's name/description/rule (re-evaluates the rule immediately if changed)","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string","nullable":true},"rule_conditions":{"allOf":[{"$ref":"#/components/schemas/RuleCondition"}],"nullable":true}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}}}},"delete":{"summary":"Delete a List (cascades its members)","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/lists/{id}/members":{"get":{"summary":"List a List's resolved members (joined Ad or Source+AdAdvertiserInfo rows)","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ListMember"}}}}}}},"post":{"summary":"Manually add a member (upgrades an existing rule-matched row to manual if already present)","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["entity_id"],"properties":{"entity_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"Added","content":{"application/json":{"schema":{"type":"object","properties":{"list_id":{"type":"integer"},"entity_id":{"type":"integer"},"added_via":{"type":"string"},"added_at":{"type":"string","format":"date-time"}}}}}}}}},"/api/lists/{id}/members/{entityId}":{"delete":{"summary":"Remove a member (manual or rule-matched — a rule-matched row simply gets re-added on the next evaluation if it still matches)","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"entityId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/lists/{id}/evaluate":{"post":{"summary":"Re-run rule evaluation for this List right now (\"Re-evaluate now\")","tags":["Lists & Rules"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"summary":{"type":"array","items":{"type":"object","properties":{"listId":{"type":"integer"},"entityType":{"type":"string"},"matching":{"type":"integer"},"added":{"type":"integer"},"removed":{"type":"integer"}}}}}}}}}}}},"/api/list-memberships":{"get":{"summary":"Bulk entity_id → [list_id, ...] membership map for one entity_type (used to render list badges without N+1 queries)","tags":["Lists & Rules"],"parameters":[{"name":"entity_type","in":"query","required":true,"schema":{"type":"string","enum":["ad","advertiser"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"array","items":{"type":"integer"}}}}}}}}},"/api/tags":{"get":{"summary":"List Tags (optionally filtered by entity_type)","tags":["Tags"],"parameters":[{"name":"entity_type","in":"query","schema":{"type":"string","enum":["ad","advertiser"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Tag"}}}}}}},"post":{"summary":"Create a Tag","tags":["Tags"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["entity_type","name"],"properties":{"entity_type":{"type":"string","enum":["ad","advertiser"]},"name":{"type":"string"}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tag"}}}}}}},"/api/tags/{id}":{"put":{"summary":"Rename a Tag","tags":["Tags"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tag"}}}}}},"delete":{"summary":"Delete a Tag (cascades entity_tags)","tags":["Tags"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/entity-tags":{"get":{"summary":"Bulk entity_id → [tag_id, ...] map for one entity_type (used to render tag chips without N+1 queries)","tags":["Tags"],"parameters":[{"name":"entity_type","in":"query","required":true,"schema":{"type":"string","enum":["ad","advertiser"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"array","items":{"type":"integer"}}}}}}}},"post":{"summary":"Attach a Tag to one ad/advertiser","tags":["Tags"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tag_id","entity_id"],"properties":{"tag_id":{"type":"integer"},"entity_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}},"delete":{"summary":"Detach a Tag from one ad/advertiser","tags":["Tags"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["tag_id","entity_id"],"properties":{"tag_id":{"type":"integer"},"entity_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/entity-tags/bulk":{"post":{"summary":"Bulk-attach one Tag to many ads/advertisers","tags":["Tags"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tag_id","entity_ids"],"properties":{"tag_id":{"type":"integer"},"entity_ids":{"type":"array","items":{"type":"integer"}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"tagged":{"type":"integer"}}}}}}}},"delete":{"summary":"Bulk-detach one Tag from many ads/advertisers","tags":["Tags"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tag_id","entity_ids"],"properties":{"tag_id":{"type":"integer"},"entity_ids":{"type":"array","items":{"type":"integer"}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"untagged":{"type":"integer"}}}}}}}}},"/api/ad-library/projects":{"get":{"summary":"List Ad Library projects (with item counts)","tags":["Ad Library"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdLibraryProject"}}}}}}},"post":{"summary":"Create an Ad Library project","tags":["Ad Library"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"description":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdLibraryProject"}}}}}}},"/api/ad-library/projects/{id}":{"put":{"summary":"Rename/edit an Ad Library project","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdLibraryProject"}}}}}},"delete":{"summary":"Delete an Ad Library project (cascades its items)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/ad-library/projects/{id}/items":{"get":{"summary":"List a project's items (ads use the same shape as GET /api/ads; concepts carry extracted_* fields)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}},"post":{"summary":"Add one item to a project — an existing ad (item_type \"ad\") or a pasted URL to capture as a concept (item_type \"concept\", server extracts title/description/image)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["item_type"],"properties":{"item_type":{"type":"string","enum":["ad","concept"]},"ad_id":{"type":"integer","nullable":true},"source_url":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Created","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/ad-library/projects/{id}/items/bulk":{"post":{"summary":"Bulk-add existing ads to a project (from the Ads tab bulk bar)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ad_ids"],"properties":{"ad_ids":{"type":"array","items":{"type":"integer"}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"added":{"type":"integer"}}}}}}}}},"/api/ad-library/projects/{id}/import-list":{"post":{"summary":"Bulk-add every ad in an existing Ad List (not Advertiser List) to a project","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["list_id"],"properties":{"list_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"imported":{"type":"integer"}}}}}}}}},"/api/ad-library/projects/{id}/import-urls":{"post":{"summary":"Bulk-import multiple pasted URLs as concepts, processed in the background via the jobs queue (one job per URL)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["urls"],"properties":{"urls":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"batch_id":{"type":"string"},"job_count":{"type":"integer"}}}}}}}}},"/api/ad-library/projects/{id}/import-jobs":{"get":{"summary":"Poll the status of a bulk-import batch (queued/running/done/failed per URL)","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"batch_id","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/ad-library/items/{id}":{"put":{"summary":"Edit a project item's notes","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object"}}}}}},"delete":{"summary":"Remove one item from a project","tags":["Ad Library"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/transcription-jobs/{postMediaId}/queue-now":{"post":{"summary":"Priority-queue a video for transcription immediately","tags":["Transcription"],"parameters":[{"name":"postMediaId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/transcription-jobs/backfill":{"post":{"summary":"Bulk-queue existing videos for transcription","tags":["Transcription"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","nullable":true},"account_id":{"type":"integer","nullable":true},"since_date":{"type":"string","format":"date","nullable":true}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"queued":{"type":"integer"}}}}}}}}},"/api/transcript-segments/{postMediaId}":{"get":{"summary":"Get the speaker-labeled transcript timeline for a video","tags":["Transcription"],"parameters":[{"name":"postMediaId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/speakers":{"get":{"summary":"List diarized speakers across all videos","tags":["Transcription"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/speakers/{id}":{"put":{"summary":"Rename a speaker","tags":["Transcription"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/speakers/merge":{"post":{"summary":"Merge two or more speaker identities into one","tags":["Transcription"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"source_ids":{"type":"array","items":{"type":"integer"}},"target_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"merged":{"type":"integer"}}}}}}}}},"/api/transcript-segments/{id}/assign-speaker":{"post":{"summary":"Assign (or reassign) which speaker a transcript segment belongs to","tags":["Transcription"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"speaker_id":{"type":"integer","nullable":true},"apply_to_all_unknown_in_media":{"type":"boolean"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/transcripts/{postMediaId}/ask":{"post":{"summary":"Ask an LLM a question about a video's transcript (needs ANTHROPIC_API_KEY)","tags":["Transcription"],"parameters":[{"name":"postMediaId","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"question":{"type":"string"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"answer":{"type":"string","nullable":true},"needsApiKey":{"type":"boolean"},"message":{"type":"string","nullable":true}}}}}}}}},"/api/posts/pull-now":{"post":{"summary":"Pull an account's posts now from a pasted handle, URL or id","tags":["Content"],"description":"Resolves the target, creates the source + account if it is not tracked yet, then queues that platform's normal post pull for the one account. Queued rather than synchronous because a real actor run can take up to 580s. Validation happens before queueing, so an unsupported platform or unaddressable target returns 400 immediately and writes nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["platform","input"],"properties":{"platform":{"type":"string","enum":["instagram","facebook","tiktok","x","youtube","linkedin"]},"input":{"type":"string","description":"A profile URL, a bare id, or a handle (with or without @)"},"count":{"type":"integer","description":"How many posts to fetch; defaults to the platform script's own tuned limit"},"since":{"type":"string","description":"Lower date bound, where the platform's actor supports one"},"until":{"type":"string","description":"Upper date bound, where the platform's actor supports one"},"source_name":{"type":"string","description":"Name for the auto-created source; defaults to the handle"}}}}}},"responses":{"200":{"description":"Queued, with the resolved identity echoed back","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"job_id":{"type":"integer"},"resolved":{"type":"object","properties":{"platform":{"type":"string"},"handle":{"type":"string"},"profile_url":{"type":"string"},"input_mode":{"type":"string","enum":["url","id","handle"]},"already_tracked":{"type":"boolean"},"script":{"type":"string"}}}}}}}},"400":{"description":"Unsupported platform, missing fields, or an unaddressable target — nothing was written"}}}},"/api/posts/pull-now/{jobId}":{"get":{"summary":"Status of an on-demand post pull, with the resulting account once done","tags":["Content"],"parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"status":{"type":"string","enum":["queued","running","done","failed"]},"error":{"type":"string","nullable":true},"payload":{"type":"object"},"account":{"type":"object","nullable":true,"description":"Present once done: the account and its stored post count"}}}}}},"404":{"description":"No post_pull_now job with that id"}}}},"/api/posts":{"get":{"summary":"List scraped posts across every tracked account","tags":["Content"],"description":"Supports optional `?limit=&offset=` pagination — omit both for the full, unpaginated array (the default, unchanged behavior). When `limit` is passed, the response is sliced server-side and the total row count (pre-slice) is returned in the `X-Total-Count` response header.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Max rows to return. Omit for the full array (default, backward-compatible)."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip before applying limit."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}}}}},"/api/comments":{"get":{"summary":"List scraped comments across every tracked post","tags":["Content"],"description":"Supports optional `?limit=&offset=` pagination — omit both for the full, unpaginated array (the default, unchanged behavior). When `limit` is passed, the response is sliced server-side and the total row count (pre-slice) is returned in the `X-Total-Count` response header.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":0},"description":"Max rows to return. Omit for the full array (default, backward-compatible)."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip before applying limit."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}}}}}},"/api/settings":{"get":{"summary":"Get every setting as a flat key/value object","tags":["Settings"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/api/settings/{key}":{"put":{"summary":"Set a single setting by key","tags":["Settings"],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"value":{}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/api/platforms":{"get":{"summary":"List supported platforms (used to populate \"add source\" forms)","tags":["Reference"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Platform"}}}}}}}},"/api/search":{"get":{"summary":"Global search across businesses/people, social accounts, and posts (Meilisearch)","tags":["Reference"],"parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"businesses_people":{"type":"array","items":{"type":"object"}},"social_accounts":{"type":"array","items":{"type":"object"}},"posts":{"type":"array","items":{"type":"object"}}}}}}}}}},"/api/costs/summary":{"get":{"summary":"API spend summary (by provider, by platform, vs. configured budgets)","tags":["Costs"],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"days":{"type":"integer"},"totalCostUsd":{"type":"number"},"byProvider":{"type":"array","items":{"type":"object"}},"byPlatform":{"type":"array","items":{"type":"object"}},"budgets":{"type":"object"}}}}}}}}},"/api/costs/timeseries":{"get":{"summary":"Daily API spend by provider","tags":["Costs"],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","format":"date"},"provider":{"type":"string"},"costUsd":{"type":"number"}}}}}}}}}},"/api/costs/log":{"get":{"summary":"Raw api_usage log rows, most recent first (has its own built-in SQL LIMIT, capped at 500)","tags":["Costs"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}}},"/api/budgets":{"get":{"summary":"List configured spend budgets","tags":["Costs"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}}}},"post":{"summary":"Create or update a spend budget","tags":["Costs"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["limitUsd"],"properties":{"provider":{"type":"string","nullable":true},"period":{"type":"string","enum":["daily","monthly"],"default":"monthly"},"limitUsd":{"type":"number"},"alertThresholdPct":{"type":"number","default":80}}}}}},"responses":{"200":{"description":"Created/updated","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid limitUsd","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/budgets/{id}":{"delete":{"summary":"Delete a spend budget","tags":["Costs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}}}}