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:
@@ -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()),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user