Initial commit: CI Agent competitive-intelligence monitoring app

FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
import { API_BASE_URL } from "./config";
import type {
AlertDetailResponse,
AlertListFilters,
AlertResponse,
AlertUpdatePayload,
ApiErrorBody,
BanIpPayload,
CompanyCreatePayload,
CompanyResponse,
CompanyUpdatePayload,
ConfirmPasswordResetPayload,
DashboardAnalytics,
DiscoverCompanyRequest,
DiscoveredCompanyProfile,
IpBan,
LogEntry,
LoginPayload,
MeResponse,
MonitorConfigurationResponse,
MonitorConfigurationUpdatePayload,
MonitoringRunResponse,
NotificationDestinationCreatePayload,
NotificationDestinationResponse,
NotificationDestinationUpdatePayload,
NotificationTestResult,
RegisterPayload,
ReportListItem,
ReportResponse,
RequestPasswordResetPayload,
ResendVerificationPayload,
SecurityEvent,
SetSystemSecretPayload,
SetUserApiKeyPayload,
SnapshotResponse,
SourceCreatePayload,
SourceResponse,
SourceTestResult,
SourceUpdatePayload,
SystemSecretStatus,
SystemStatus,
TokenResponse,
UnbanRequestPayload,
UnbanRequestRecord,
UserApiKeyStatus,
UserResponse,
VerifyEmailPayload,
} from "./types";
const ACCESS_TOKEN_KEY = "ciagent_access_token";
const REFRESH_TOKEN_KEY = "ciagent_refresh_token";
// Local-storage token persistence keeps this MVP's client simple to run
// entirely outside Docker/without a backend session store. Tokens are
// short-lived (see JWT_ACCESS_TOKEN_MINUTES) and refresh tokens rotate on
// use; see KNOWN_LIMITATIONS.md for the httpOnly-cookie hardening this
// would need before a real multi-user production deployment.
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string): void {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export class ApiError extends Error {
constructor(
public status: number,
public body: ApiErrorBody | undefined,
) {
super(body?.detail ?? `Request failed with status ${status}`);
this.name = "ApiError";
}
}
interface RequestOptions extends RequestInit {
auth?: boolean;
}
// Access tokens are short-lived (JWT_ACCESS_TOKEN_MINUTES, 15min) while
// refresh tokens last days (JWT_REFRESH_TOKEN_DAYS, 7) - without this, any
// idle period past 15 minutes turned every authenticated request into a
// hard 401, which bounced the dashboard layout to /login while /login's own
// stale-cache "am I logged in?" check bounced right back, flickering
// between the two. A single in-flight refresh is shared across concurrent
// 401s so simultaneous requests don't each rotate (and invalidate) the
// refresh token out from under one another.
let refreshPromise: Promise<void> | null = null;
async function performRefresh(): Promise<void> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new ApiError(401, { detail: "No refresh token available" });
}
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
throw new ApiError(response.status, undefined);
}
const tokens = (await response.json()) as TokenResponse;
setTokens(tokens.access_token, tokens.refresh_token);
}
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
const { auth = true, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
finalHeaders.set("Content-Type", "application/json");
if (auth) {
const token = getAccessToken();
if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...rest, headers: finalHeaders });
if (response.status === 401 && auth && !isRetry && getRefreshToken()) {
try {
refreshPromise ??= performRefresh().finally(() => {
refreshPromise = null;
});
await refreshPromise;
return request<T>(path, options, true);
} catch {
// Refresh itself failed (refresh token also expired/revoked) - fall
// through and report the original 401 below, which is now a real,
// final "you're actually logged out" rather than a transient one.
}
}
if (!response.ok) {
let body: ApiErrorBody | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
throw new ApiError(response.status, body);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
systemStatus: () => request<SystemStatus>("/api/v1/system/status", { auth: false }),
systemLogs: () => request<LogEntry[]>("/api/v1/system/logs"),
listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"),
setSystemSecret: (key: string, payload: SetSystemSecretPayload) =>
request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
register: (payload: RegisterPayload) =>
request<UserResponse>("/api/v1/auth/register", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
login: (payload: LoginPayload) =>
request<TokenResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
refresh: (refreshToken: string) =>
request<TokenResponse>("/api/v1/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
auth: false,
}),
logout: (refreshToken: string) =>
request<void>("/api/v1/auth/logout", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
}),
me: () => request<MeResponse>("/api/v1/auth/me"),
verifyEmail: (payload: VerifyEmailPayload) =>
request<void>("/api/v1/auth/verify-email", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
resendVerification: (payload: ResendVerificationPayload) =>
request<void>("/api/v1/auth/resend-verification", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
requestPasswordReset: (payload: RequestPasswordResetPayload) =>
request<void>("/api/v1/auth/request-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
confirmPasswordReset: (payload: ConfirmPasswordResetPayload) =>
request<void>("/api/v1/auth/confirm-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
securityEvents: () => request<SecurityEvent[]>("/api/v1/auth/security-events"),
submitUnbanRequest: (payload: UnbanRequestPayload) =>
request<void>("/api/v1/unban-requests", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
listIpBans: () => request<IpBan[]>("/api/v1/admin/ip-bans"),
createIpBan: (payload: BanIpPayload) =>
request<IpBan>("/api/v1/admin/ip-bans", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteIpBan: (ipAddress: string) =>
request<void>(`/api/v1/admin/ip-bans/${encodeURIComponent(ipAddress)}`, {
method: "DELETE",
}),
listUnbanRequests: () => request<UnbanRequestRecord[]>("/api/v1/admin/unban-requests"),
acceptUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}/accept`, { method: "POST" }),
rejectUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}`, { method: "DELETE" }),
listUserApiKeys: () => request<UserApiKeyStatus[]>("/api/v1/user-api-keys"),
setUserApiKey: (provider: string, payload: SetUserApiKeyPayload) =>
request<UserApiKeyStatus>(`/api/v1/user-api-keys/${provider}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
listCompanies: () => request<CompanyResponse[]>("/api/v1/companies"),
discoverCompany: (payload: DiscoverCompanyRequest) =>
request<DiscoveredCompanyProfile>("/api/v1/companies/discover", {
method: "POST",
body: JSON.stringify(payload),
}),
getCompany: (companyId: string) => request<CompanyResponse>(`/api/v1/companies/${companyId}`),
createCompany: (payload: CompanyCreatePayload) =>
request<CompanyResponse>("/api/v1/companies", {
method: "POST",
body: JSON.stringify(payload),
}),
updateCompany: (companyId: string, payload: CompanyUpdatePayload) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteCompany: (companyId: string) =>
request<void>(`/api/v1/companies/${companyId}`, { method: "DELETE" }),
pauseCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/pause`, { method: "POST" }),
resumeCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
listNotificationDestinations: () =>
request<NotificationDestinationResponse[]>("/api/v1/notification-destinations"),
createNotificationDestination: (payload: NotificationDestinationCreatePayload) =>
request<NotificationDestinationResponse>("/api/v1/notification-destinations", {
method: "POST",
body: JSON.stringify(payload),
}),
updateNotificationDestination: (
destinationId: string,
payload: NotificationDestinationUpdatePayload,
) =>
request<NotificationDestinationResponse>(`/api/v1/notification-destinations/${destinationId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteNotificationDestination: (destinationId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}`, { method: "DELETE" }),
unlinkNotificationDestinationCompany: (destinationId: string, companyId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}/companies/${companyId}`, {
method: "DELETE",
}),
testNotificationDestination: (destinationId: string) =>
request<NotificationTestResult>(`/api/v1/notification-destinations/${destinationId}/test`, {
method: "POST",
}),
listAlerts: (filters: AlertListFilters = {}) => {
const params = new URLSearchParams();
if (filters.company_id) params.set("company_id", filters.company_id);
if (filters.severity) params.set("severity", filters.severity);
if (filters.read !== undefined) params.set("read", String(filters.read));
if (filters.resolved !== undefined) params.set("resolved", String(filters.resolved));
const query = params.toString();
return request<AlertResponse[]>(`/api/v1/alerts${query ? `?${query}` : ""}`);
},
getAlert: (alertId: string) => request<AlertDetailResponse>(`/api/v1/alerts/${alertId}`),
updateAlert: (alertId: string, payload: AlertUpdatePayload) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
markAlertRead: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/read`, { method: "POST" }),
resolveAlert: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/resolve`, { method: "POST" }),
getDashboardAnalytics: () => request<DashboardAnalytics>("/api/v1/dashboard/analytics"),
listSources: (companyId: string) =>
request<SourceResponse[]>(`/api/v1/companies/${companyId}/sources`),
listSnapshots: (companyId: string) =>
request<SnapshotResponse[]>(`/api/v1/companies/${companyId}/snapshots`),
createSource: (companyId: string, payload: SourceCreatePayload) =>
request<SourceResponse>(`/api/v1/companies/${companyId}/sources`, {
method: "POST",
body: JSON.stringify(payload),
}),
updateSource: (sourceId: string, payload: SourceUpdatePayload) =>
request<SourceResponse>(`/api/v1/sources/${sourceId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteSource: (sourceId: string) =>
request<void>(`/api/v1/sources/${sourceId}`, { method: "DELETE" }),
testSource: (sourceId: string) =>
request<SourceTestResult>(`/api/v1/sources/${sourceId}/test`, { method: "POST" }),
runCompanyNow: (companyId: string) =>
request<MonitoringRunResponse>(`/api/v1/companies/${companyId}/run`, { method: "POST" }),
listCompanyRuns: (companyId: string) =>
request<MonitoringRunResponse[]>(`/api/v1/companies/${companyId}/runs`),
getRun: (runId: string) => request<MonitoringRunResponse>(`/api/v1/runs/${runId}`),
listReports: (companyId: string) =>
request<ReportListItem[]>(`/api/v1/companies/${companyId}/reports`),
generateReport: (companyId: string) =>
request<ReportResponse>(`/api/v1/companies/${companyId}/reports/generate`, {
method: "POST",
}),
getReport: (reportId: string) => request<ReportResponse>(`/api/v1/reports/${reportId}`),
getReportMarkdown: (reportId: string) =>
fetch(`${API_BASE_URL}/api/v1/reports/${reportId}/markdown`, {
headers: (() => {
const h = new Headers();
const token = getAccessToken();
if (token) h.set("Authorization", `Bearer ${token}`);
return h;
})(),
}).then((r) => r.text()),
};
+13
View File
@@ -0,0 +1,13 @@
import type { SystemStatus } from "./types";
/**
* True when this connection is getting the local-dev free pass (fixed
* account, no login) - both that the operator hasn't forced full lockdown
* (`auth_mode === "jwt"`) and that the backend actually recognized this
* specific request as coming from loopback (`is_localhost`). Used anywhere
* the UI needs to decide whether to show a login screen, a sign-out
* button, or local-dev-only copy.
*/
export function isLocalConvenience(status: SystemStatus): boolean {
return status.auth_mode !== "jwt" && status.is_localhost;
}
+1
View File
@@ -0,0 +1 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
+76
View File
@@ -0,0 +1,76 @@
import { format, formatDistanceToNow } from "date-fns";
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
return format(new Date(iso), "MMM d, yyyy h:mm a");
}
export function formatRelative(iso: string | null | undefined): string {
if (!iso) return "—";
return formatDistanceToNow(new Date(iso), { addSuffix: true });
}
/**
* NinjaPear's funding fields (`total_raised`, a round's `amount`) come back
* as raw numeric strings (e.g. "500000000"), with no currency formatting.
* Turns that into "$500,000,000"; leaves already-formatted or non-numeric
* values (e.g. "$1M", "Undisclosed") untouched.
*/
export function formatMoney(value: string): string {
const trimmed = value.trim();
if (!trimmed || !/^\d+(\.\d+)?$/.test(trimmed)) return trimmed;
const amount = Number(trimmed);
if (!Number.isFinite(amount)) return trimmed;
return `$${amount.toLocaleString("en-US")}`;
}
/**
* NinjaPear's competitor/customer `name` fields sometimes come back as a
* bare URL (e.g. "https://squareup.com") rather than a company name. Turns
* that into something readable; leaves already-clean names untouched.
*/
export function companyNameFromUrl(value: string): string {
const trimmed = value.trim();
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
if (!withoutProtocol.includes(".")) return trimmed;
const label = withoutProtocol.split("/")[0]?.split(".")[0];
if (!label) return trimmed;
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase();
}
const ENRICHMENT_REASON_LABELS: Record<string, string> = {
product_overlap: "Similar products",
organic_keyword_overlap: "Overlapping search keywords",
};
/** Turns a NinjaPear competitor-match slug (e.g. "product_overlap") into a
* human-readable label; unrecognized slugs still read as words. */
export function formatEnrichmentReason(reason: string): string {
const known = ENRICHMENT_REASON_LABELS[reason];
if (known) return known;
return reason
.split("_")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/** Light heuristics to turn a raw exception string from an enrichment
* section failure into a short, non-technical summary. */
export function summarizeEnrichmentError(raw: string): string {
const firstLine = raw.split("\n")[0]?.trim() ?? raw;
if (/^\d+ validation errors?/i.test(firstLine)) {
return "The data returned didn't match the expected format.";
}
if (/404 not found/i.test(raw)) {
return "The requested data wasn't found.";
}
if (/timed? ?out/i.test(raw)) {
return "The request timed out.";
}
if (/\b5\d\d\b/.test(raw) && /error/i.test(raw)) {
return "The data provider returned a server error.";
}
return firstLine.length > 100 ? `${firstLine.slice(0, 100)}` : firstLine;
}
+588
View File
@@ -0,0 +1,588 @@
// Mirrors apps/api/app/schemas/*.py. Kept in sync by hand for now (see
// packages/shared for the plain-constant subset shared cross-language).
export interface SystemStatusComponent {
name: string;
status: "ok" | "error";
detail: string | null;
}
export interface SystemStatus {
app_env: string;
auth_mode: "local" | "jwt";
llm_provider: string;
search_provider: string;
sms_enabled: boolean;
sms_provider: string;
ninjapear_configured: boolean;
ninjapear_credit_balance: number | null;
ninjapear_estimated_credits_per_company: number | null;
is_localhost: boolean;
turnstile_site_key: string | null;
components: SystemStatusComponent[];
}
export interface SystemSecretStatus {
key: string;
label: string;
configured: boolean;
value: string | null;
}
export interface SetSystemSecretPayload {
value: string;
}
export interface UserApiKeyStatus {
provider: string;
label: string;
configured: boolean;
value: string | null;
credits: number | null;
credits_note: string | null;
free: boolean;
requires_government_id: boolean;
}
export interface SetUserApiKeyPayload {
key: string;
}
export type LogCategory = "internal_error" | "api_error" | "important" | "normal";
export interface LogEntry {
ts: string;
level: string;
category: LogCategory;
logger: string;
event: string;
context: Record<string, unknown>;
}
export interface UserResponse {
id: string;
email: string;
display_name: string;
timezone: string;
is_active: boolean;
is_admin: boolean;
}
export interface MeResponse extends UserResponse {
auth_mode: "local" | "jwt";
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in_minutes: number;
}
export interface RegisterPayload {
email: string;
password: string;
display_name: string;
timezone?: string;
turnstile_token?: string;
}
export interface LoginPayload {
email: string;
password: string;
turnstile_token?: string;
}
export interface ApiErrorBody {
detail?: string;
errors?: Array<{ msg?: string; loc?: (string | number)[] }>;
retry_after_seconds?: number;
}
export interface VerifyEmailPayload {
email: string;
code: string;
}
export interface ResendVerificationPayload {
email: string;
turnstile_token?: string;
}
export interface RequestPasswordResetPayload {
email: string;
turnstile_token?: string;
}
export interface ConfirmPasswordResetPayload {
email: string;
code: string;
new_password: string;
}
export interface SecurityEvent {
event_type: string;
ip_address: string;
created_at: string;
}
export interface IpBan {
ip_address: string;
banned_at: string;
reason: string;
}
export interface BanIpPayload {
ip_address: string;
}
export interface UnbanRequestPayload {
message?: string;
}
export interface UnbanRequestRecord {
id: string;
ip_address: string;
message: string | null;
created_at: string;
}
export const MONITORING_FREQUENCIES = [
"hourly",
"every_6_hours",
"every_12_hours",
"daily",
"every_2_days",
"weekly",
"every_2_weeks",
"monthly",
"custom",
] as const;
export type MonitoringFrequency = (typeof MONITORING_FREQUENCIES)[number];
export const FREQUENCY_LABELS: Record<MonitoringFrequency, string> = {
hourly: "Hourly",
every_6_hours: "Every 6 hours",
every_12_hours: "Every 12 hours",
daily: "Daily",
every_2_days: "Every 2 days",
weekly: "Weekly",
every_2_weeks: "Every 2 weeks",
monthly: "Monthly",
custom: "Custom",
};
export const SEVERITY_LEVELS = ["critical", "high", "medium", "low"] as const;
export type SeverityLevel = (typeof SEVERITY_LEVELS)[number];
export const SEVERITY_LABELS: Record<SeverityLevel, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export type CompanyStatus = "active" | "paused";
export interface MonitorConfigurationResponse {
id: string;
frequency_type: MonitoringFrequency;
interval_minutes: number | null;
cron_expression: string | null;
timezone: string;
enabled: boolean;
next_run: string | null;
last_run: string | null;
severity_threshold: SeverityLevel;
}
export interface MonitorConfigurationUpdatePayload {
frequency_type?: MonitoringFrequency;
interval_minutes?: number | null;
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
severity_threshold?: SeverityLevel;
}
export type EnrichmentStatus = "pending" | "partial" | "complete" | "failed";
export interface CompanyEnrichmentResponse {
status: EnrichmentStatus;
data: Record<string, unknown>;
errors: Record<string, string>;
credits_spent: number | null;
fetched_at: string | null;
}
export interface CompanyResponse {
id: string;
name: string;
slug: string;
official_website: string | null;
description: string | null;
monitoring_focus: string | null;
industry: string | null;
country: string | null;
region: string | null;
headquarters: string | null;
public_identifiers: Record<string, string>;
status: CompanyStatus;
created_at: string;
updated_at: string;
aliases: string[];
competitors: string[];
monitor_configuration: MonitorConfigurationResponse | null;
enrichment: CompanyEnrichmentResponse | null;
report_count: number;
unresolved_alert_count: number;
}
export interface CompanyCreatePayload {
name: string;
official_website?: string | null;
description?: string | null;
monitoring_focus?: string | null;
industry?: string | null;
country?: string | null;
region?: string | null;
headquarters?: string | null;
public_identifiers?: Record<string, string>;
competitor_names?: string[];
alias_names?: string[];
frequency_type?: MonitoringFrequency;
interval_minutes?: number | null;
cron_expression?: string | null;
timezone?: string;
severity_threshold?: SeverityLevel;
}
export interface CompanyUpdatePayload {
name?: string;
official_website?: string | null;
description?: string | null;
monitoring_focus?: string | null;
industry?: string | null;
country?: string | null;
region?: string | null;
headquarters?: string | null;
public_identifiers?: Record<string, string>;
competitor_names?: string[];
alias_names?: string[];
}
export interface DiscoverCompanyRequest {
name: string;
official_website?: string | null;
monitoring_focus?: string | null;
competitor_names?: string[];
alias_names?: string[];
}
export interface PotentialSource {
source_type: SourceType;
name: string;
base_url: string | null;
}
export interface DiscoveredCompanyProfile {
name: string;
official_website: string | null;
description: string | null;
monitoring_focus: string | null;
industry: string | null;
country: string | null;
region: string | null;
headquarters: string | null;
aliases: string[];
competitors: string[];
public_identifiers: Record<string, string>;
potential_sources: PotentialSource[];
sources_consulted: string[];
}
export type NotificationType = "email" | "sms" | "console";
export interface LinkedCompany {
id: string;
name: string;
}
export interface NotificationDestinationResponse {
id: string;
type: NotificationType;
destination_value: string;
verified: boolean;
enabled: boolean;
minimum_severity: SeverityLevel;
created_at: string;
companies: LinkedCompany[];
}
export interface NotificationDestinationCreatePayload {
type: NotificationType;
destination_value: string;
minimum_severity?: SeverityLevel;
enabled?: boolean;
company_ids: string[];
}
export interface NotificationDestinationUpdatePayload {
destination_value?: string;
enabled?: boolean;
minimum_severity?: SeverityLevel;
}
export interface NotificationTestResult {
success: boolean;
error: string | null;
}
export type NotificationDeliveryStatus = "pending" | "sent" | "failed";
export interface NotificationDeliverySummary {
id: string;
destination_id: string;
provider: string;
status: NotificationDeliveryStatus;
external_message_id: string | null;
error_message: string | null;
}
export interface AlertResponse {
id: string;
company_id: string;
detected_change_id: string;
title: string;
summary: string;
why_it_matters: string;
severity: SeverityLevel;
confidence: number;
read: boolean;
resolved: boolean;
created_at: string;
}
export interface AlertDetailResponse extends AlertResponse {
deliveries: NotificationDeliverySummary[];
}
export interface AlertUpdatePayload {
read?: boolean;
resolved?: boolean;
}
export interface AlertListFilters {
company_id?: string;
severity?: SeverityLevel;
read?: boolean;
resolved?: boolean;
}
export type SourceType =
| "website"
| "rss"
| "custom_url"
| "sec_edgar"
| "github"
| "job_posting"
| "patent"
| "review"
| "gov_contract";
export type SourceStatus =
| "active"
| "disabled"
| "rate_limited"
| "auth_required"
| "blocked_by_policy"
| "failed";
export interface SourceResponse {
id: string;
source_type: SourceType;
name: string;
base_url: string | null;
active: boolean;
status: SourceStatus;
trust_score: number;
last_checked: string | null;
last_successful_check: string | null;
failure_count: number;
// null means "inherit the company's default monitoring cadence".
frequency_type: MonitoringFrequency | null;
interval_minutes: number | null;
cron_expression: string | null;
next_check: string | null;
}
export interface SourceCreatePayload {
source_type: "custom_url" | "rss";
name: string;
base_url: string;
}
export interface SourceUpdatePayload {
name?: string;
active?: boolean;
frequency_type?: MonitoringFrequency | null;
interval_minutes?: number | null;
cron_expression?: string | null;
}
export interface SourceTestResult {
status: SourceStatus;
documents_found: number;
error: string | null;
}
export interface SnapshotResponse {
id: string;
source_id: string;
snapshot_type: string;
hash: string;
text_summary: string | null;
structured_summary: Record<string, unknown>;
monitoring_run_id: string | null;
created_at: string;
}
export type ReportType = "baseline" | "update" | "monthly" | "manual";
export type ConfidenceLabel =
| "confirmed"
| "strongly_indicated"
| "likely"
| "possible"
| "unconfirmed"
| "insufficient_evidence";
export interface EvidenceRef {
source_document_id: string | null;
detected_change_id: string | null;
url: string | null;
description: string;
}
export interface Finding {
headline: string;
summary: string;
evidence: EvidenceRef[];
confidence: ConfidenceLabel;
category: string | null;
date: string | null;
}
export interface InferredProject {
project_name: string;
status: ConfidenceLabel;
summary: string;
confidence: number;
evidence: EvidenceRef[];
signal_types: string[];
alternative_explanations: string[];
}
export interface SwotAnalysis {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
}
export interface ReportContent {
executive_summary: string;
company_overview: string;
products_and_services: Finding[];
market_positioning: string;
recent_developments: Finding[];
strategic_initiatives: Finding[];
key_inferred_projects: InferredProject[];
leadership_changes: Finding[];
hiring_signals: Finding[];
technology_signals: Finding[];
patent_signals: Finding[];
manufacturing_and_expansion_signals: Finding[];
partnerships_and_acquisitions: Finding[];
financial_signals: Finding[];
regulatory_and_legal_signals: Finding[];
customer_sentiment: string;
competitor_comparison: string;
swot: SwotAnalysis;
risks: string[];
opportunities: string[];
unknowns_and_missing_data: string[];
monitoring_recommendations: string[];
methodology: string;
limitations: string;
}
export interface ReportListItem {
id: string;
report_type: ReportType;
title: string;
executive_summary: string;
model_provider: string;
model_name: string;
created_at: string;
}
export interface ReportResponse extends ReportListItem {
company_id: string;
monitoring_run_id: string | null;
structured_report: ReportContent;
prompt_version: string;
}
export interface RunsByDayPoint {
date: string;
successful: number;
failed: number;
other: number;
}
export interface RecentSignal {
id: string;
company_id: string;
company_name: string;
change_type: string;
severity: SeverityLevel;
confidence_score: number;
summary: string;
created_at: string;
}
export interface DashboardAnalytics {
changes_by_type: Record<string, number>;
alerts_by_severity: Record<string, number>;
sources_by_status: Record<string, number>;
runs_by_day: RunsByDayPoint[];
recent_signals: RecentSignal[];
}
export const CHANGE_TYPE_LABELS: Record<string, string> = {
new_document: "New document",
removed_document: "Removed document",
content_modified: "Content modified",
price_change: "Price change",
leadership_change: "Leadership change",
filing_new: "New filing",
};
export type MonitoringRunTrigger = "scheduled" | "manual" | "initial" | "retry";
export type MonitoringRunStatus = "queued" | "running" | "successful" | "partial" | "failed";
export interface MonitoringRunResponse {
id: string;
company_id: string;
trigger_type: MonitoringRunTrigger;
status: MonitoringRunStatus;
started_at: string | null;
completed_at: string | null;
sources_attempted: number;
sources_successful: number;
sources_failed: number;
items_collected: number;
changes_detected: number;
error_summary: string | null;
created_at: string;
}