From e3d2cf43212f25fb8c975ecd3f7dc83c2d7b92d4 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 10:35:21 +0530 Subject: [PATCH 01/55] feat(engine): competitor structured diff --- .../src/competitor/__tests__/diff.test.ts | 30 ++++++++++++++ packages/engine/src/competitor/diff.ts | 41 +++++++++++++++++++ packages/engine/src/competitor/types.ts | 17 ++++++++ 3 files changed, 88 insertions(+) create mode 100644 packages/engine/src/competitor/__tests__/diff.test.ts create mode 100644 packages/engine/src/competitor/diff.ts create mode 100644 packages/engine/src/competitor/types.ts diff --git a/packages/engine/src/competitor/__tests__/diff.test.ts b/packages/engine/src/competitor/__tests__/diff.test.ts new file mode 100644 index 00000000..b27e107b --- /dev/null +++ b/packages/engine/src/competitor/__tests__/diff.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { diffStructured } from "../diff"; + +describe("diffStructured", () => { + it("returns [] for deep-equal objects", () => { + expect(diffStructured({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toEqual([]); + }); + + it("detects a modified primitive with full path", () => { + const out = diffStructured({ plan: { price: 29 } }, { plan: { price: 39 } }); + expect(out).toEqual([{ kind: "modified", path: ["plan", "price"], before: 29, after: 39 }]); + }); + + it("detects added and removed keys", () => { + const out = diffStructured({ a: 1 }, { a: 1, b: 2 }); + expect(out).toContainEqual({ kind: "added", path: ["b"], after: 2 }); + const out2 = diffStructured({ a: 1, b: 2 }, { a: 1 }); + expect(out2).toContainEqual({ kind: "removed", path: ["b"], before: 2 }); + }); + + it("diffs arrays index-wise including length growth", () => { + const out = diffStructured({ plans: [{ n: "Pro" }] }, { plans: [{ n: "Pro" }, { n: "Team" }] }); + expect(out).toContainEqual({ kind: "added", path: ["plans", "1"], after: { n: "Team" } }); + }); + + it("produces deterministic ordering (sorted by path)", () => { + const out = diffStructured({ b: 1, a: 1 }, { b: 2, a: 2 }); + expect(out.map((c) => c.path.join("."))).toEqual(["a", "b"]); + }); +}); diff --git a/packages/engine/src/competitor/diff.ts b/packages/engine/src/competitor/diff.ts new file mode 100644 index 00000000..fe84781d --- /dev/null +++ b/packages/engine/src/competitor/diff.ts @@ -0,0 +1,41 @@ +import type { Change } from "./types"; + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function deepEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function walk(prev: unknown, next: unknown, path: string[], out: Change[]): void { + if (deepEqual(prev, next)) return; + + const prevIsObj = isObject(prev); + const nextIsObj = isObject(next); + + if (!prevIsObj || !nextIsObj) { + out.push({ kind: "modified", path, before: prev, after: next }); + return; + } + + const keys = Array.from(new Set([...Object.keys(prev), ...Object.keys(next)])).sort(); + for (const key of keys) { + const hasPrev = key in prev; + const hasNext = key in next; + const childPath = [...path, key]; + if (hasPrev && !hasNext) { + out.push({ kind: "removed", path: childPath, before: prev[key] }); + } else if (!hasPrev && hasNext) { + out.push({ kind: "added", path: childPath, after: next[key] }); + } else { + walk(prev[key], next[key], childPath, out); + } + } +} + +export function diffStructured(prev: unknown, next: unknown): Change[] { + const out: Change[] = []; + walk(prev, next, [], out); + return out; +} diff --git a/packages/engine/src/competitor/types.ts b/packages/engine/src/competitor/types.ts new file mode 100644 index 00000000..c1d11ed2 --- /dev/null +++ b/packages/engine/src/competitor/types.ts @@ -0,0 +1,17 @@ +export type Severity = "info" | "success" | "warning" | "error"; +export type StructuredPayload = Record; + +export interface Change { + kind: "added" | "removed" | "modified"; + path: string[]; + before?: unknown; + after?: unknown; +} + +export interface Alert { + changeType: string; + summary: string; + severity: Severity; + before?: unknown; + after?: unknown; +} From 4bd4ffe9de939b8a8bb373f429e558e1a3ffa1cb Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 10:39:15 +0530 Subject: [PATCH 02/55] feat(engine): competitor rule/alert layer --- .../src/competitor/__tests__/rules.test.ts | 51 +++++++++++++ packages/engine/src/competitor/index.ts | 3 + packages/engine/src/competitor/rules.ts | 71 +++++++++++++++++++ packages/engine/src/index.ts | 3 + 4 files changed, 128 insertions(+) create mode 100644 packages/engine/src/competitor/__tests__/rules.test.ts create mode 100644 packages/engine/src/competitor/index.ts create mode 100644 packages/engine/src/competitor/rules.ts diff --git a/packages/engine/src/competitor/__tests__/rules.test.ts b/packages/engine/src/competitor/__tests__/rules.test.ts new file mode 100644 index 00000000..88f0ea33 --- /dev/null +++ b/packages/engine/src/competitor/__tests__/rules.test.ts @@ -0,0 +1,51 @@ +// packages/engine/src/competitor/__tests__/rules.test.ts +import { describe, it, expect } from "vitest"; +import { evaluateRules } from "../rules"; +import type { Change } from "../types"; + +describe("evaluateRules — pricing", () => { + it("flags a >=10% price increase as warning with % in summary", () => { + const changes: Change[] = [ + { kind: "modified", path: ["plans", "0", "price", "amount"], before: 29, after: 39 }, + ]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("price_increase"); + expect(alert.severity).toBe("warning"); + expect(alert.summary).toContain("29"); + expect(alert.summary).toContain("39"); + expect(alert.summary).toContain("34"); // ~+34% + }); + + it("flags a small price decrease as info", () => { + const changes: Change[] = [ + { kind: "modified", path: ["plans", "0", "price", "amount"], before: 100, after: 95 }, + ]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("price_decrease"); + expect(alert.severity).toBe("info"); + }); + + it("flags an added plan", () => { + const changes: Change[] = [{ kind: "added", path: ["plans", "2"], after: { name: "Team" } }]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("plan_added"); + }); +}); + +describe("evaluateRules — social", () => { + it("flags follower growth", () => { + const changes: Change[] = [{ kind: "modified", path: ["followers"], before: 1000, after: 2200 }]; + const [alert] = evaluateRules("social", changes); + expect(alert.changeType).toBe("followers_up"); + expect(alert.summary).toContain("1,200"); + }); +}); + +describe("evaluateRules — fallback", () => { + it("emits a generic field_changed alert for unknown paths (never drops)", () => { + const changes: Change[] = [{ kind: "modified", path: ["mystery"], before: "a", after: "b" }]; + const out = evaluateRules("pricing", changes); + expect(out).toHaveLength(1); + expect(out[0].changeType).toBe("field_changed"); + }); +}); diff --git a/packages/engine/src/competitor/index.ts b/packages/engine/src/competitor/index.ts new file mode 100644 index 00000000..c8b2a4c5 --- /dev/null +++ b/packages/engine/src/competitor/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./diff"; +export * from "./rules"; diff --git a/packages/engine/src/competitor/rules.ts b/packages/engine/src/competitor/rules.ts new file mode 100644 index 00000000..787403ea --- /dev/null +++ b/packages/engine/src/competitor/rules.ts @@ -0,0 +1,71 @@ +import { D, dRound2 } from "../decimal"; +import type { Alert, Change, Severity } from "./types"; + +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function fmtInt(n: number): string { + return n.toLocaleString("en-US"); +} + +function pricingAlert(c: Change): Alert | null { + const last = c.path[c.path.length - 1]; + if (c.kind === "added" && c.path[0] === "plans" && c.path.length === 2) { + return { changeType: "plan_added", summary: "New pricing plan added", severity: "info", after: c.after }; + } + if (c.kind === "removed" && c.path[0] === "plans" && c.path.length === 2) { + return { changeType: "plan_removed", summary: "Pricing plan removed", severity: "info", before: c.before }; + } + if (c.kind === "modified" && last === "amount") { + const before = num(c.before); + const after = num(c.after); + if (before !== null && after !== null && before > 0) { + const pct = dRound2(D(after).minus(before).div(before).times(100)); + const up = after > before; + const sev: Severity = Math.abs(pct) >= 10 ? "warning" : "info"; + return { + changeType: up ? "price_increase" : "price_decrease", + summary: `Plan price ${before} → ${after} (${up ? "+" : ""}${pct}%)`, + severity: sev, + before, + after, + }; + } + } + return null; +} + +function socialAlert(c: Change): Alert | null { + if (c.kind === "modified" && c.path[c.path.length - 1] === "followers") { + const before = num(c.before); + const after = num(c.after); + if (before !== null && after !== null) { + const delta = after - before; + const up = delta >= 0; + return { + changeType: up ? "followers_up" : "followers_down", + summary: `${up ? "+" : ""}${fmtInt(delta)} followers (${fmtInt(before)} → ${fmtInt(after)})`, + severity: "info", + before, + after, + }; + } + } + return null; +} + +function genericAlert(c: Change): Alert { + return { + changeType: "field_changed", + summary: `${c.kind} at ${c.path.join(".") || "(root)"}`, + severity: "info", + before: c.before, + after: c.after, + }; +} + +export function evaluateRules(type: string, changes: Change[]): Alert[] { + const specific = type === "pricing" ? pricingAlert : type === "social" ? socialAlert : null; + return changes.map((c) => (specific && specific(c)) ?? genericAlert(c)); +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6f8420ea..a2ee5787 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -247,6 +247,9 @@ export type { NormalizedWebhookEvent, } from "./payments"; +// Competitor analysis — diff, rules, alert layer +export * from "./competitor"; + // Bank connectors — import directly from "@burnless/engine/bank-connectors" when needed. // Not re-exported here to avoid bundling optional plaid SDK. export type { From 0a7c1d742fe732c8194d324a7ee1d2d8faef1821 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 10:42:57 +0530 Subject: [PATCH 03/55] feat(db): competitor schema (competitors/sources/snapshots/changes) Adds 4 new tables for the competitor analysis spine: - competitors, competitor_sources, competitor_snapshots, competitor_changes All company-scoped with cascade FKs and appropriate indexes. Generated additive migration via drizzle-kit (0015_shocking_lord_tyger.sql). Co-Authored-By: Claude Sonnet 4.6 --- .../db/drizzle/0015_shocking_lord_tyger.sql | 71 + packages/db/drizzle/meta/0015_snapshot.json | 8816 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 1 + packages/db/src/schema/competitor.ts | 134 + 5 files changed, 9029 insertions(+) create mode 100644 packages/db/drizzle/0015_shocking_lord_tyger.sql create mode 100644 packages/db/drizzle/meta/0015_snapshot.json create mode 100644 packages/db/src/schema/competitor.ts diff --git a/packages/db/drizzle/0015_shocking_lord_tyger.sql b/packages/db/drizzle/0015_shocking_lord_tyger.sql new file mode 100644 index 00000000..8c127065 --- /dev/null +++ b/packages/db/drizzle/0015_shocking_lord_tyger.sql @@ -0,0 +1,71 @@ +CREATE TABLE "competitor_changes" ( + "id" text PRIMARY KEY NOT NULL, + "competitor_id" text NOT NULL, + "source_id" text NOT NULL, + "snapshot_id" text NOT NULL, + "company_id" text NOT NULL, + "detected_at" timestamp DEFAULT now() NOT NULL, + "change_type" text NOT NULL, + "summary" text NOT NULL, + "before" jsonb, + "after" jsonb, + "severity" text DEFAULT 'info' NOT NULL, + "acknowledged_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "competitor_snapshots" ( + "id" text PRIMARY KEY NOT NULL, + "competitor_id" text NOT NULL, + "source_id" text NOT NULL, + "company_id" text NOT NULL, + "captured_at" timestamp DEFAULT now() NOT NULL, + "raw" text NOT NULL, + "raw_hash" text NOT NULL, + "structured" jsonb NOT NULL, + "structured_hash" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "competitor_sources" ( + "id" text PRIMARY KEY NOT NULL, + "competitor_id" text NOT NULL, + "company_id" text NOT NULL, + "type" text NOT NULL, + "url" text NOT NULL, + "config" jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "interval_hours" integer DEFAULT 168 NOT NULL, + "last_run_at" timestamp, + "last_status" text, + "health_state" text DEFAULT 'ok' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "competitors" ( + "id" text PRIMARY KEY NOT NULL, + "company_id" text NOT NULL, + "name" text NOT NULL, + "url" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "competitor_changes" ADD CONSTRAINT "competitor_changes_competitor_id_competitors_id_fk" FOREIGN KEY ("competitor_id") REFERENCES "public"."competitors"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_changes" ADD CONSTRAINT "competitor_changes_source_id_competitor_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."competitor_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_changes" ADD CONSTRAINT "competitor_changes_snapshot_id_competitor_snapshots_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."competitor_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_changes" ADD CONSTRAINT "competitor_changes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_snapshots" ADD CONSTRAINT "competitor_snapshots_competitor_id_competitors_id_fk" FOREIGN KEY ("competitor_id") REFERENCES "public"."competitors"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_snapshots" ADD CONSTRAINT "competitor_snapshots_source_id_competitor_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."competitor_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_snapshots" ADD CONSTRAINT "competitor_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_sources" ADD CONSTRAINT "competitor_sources_competitor_id_competitors_id_fk" FOREIGN KEY ("competitor_id") REFERENCES "public"."competitors"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitor_sources" ADD CONSTRAINT "competitor_sources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "competitors" ADD CONSTRAINT "competitors_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "competitor_changes_company_idx" ON "competitor_changes" USING btree ("company_id","detected_at");--> statement-breakpoint +CREATE INDEX "competitor_changes_competitor_idx" ON "competitor_changes" USING btree ("competitor_id");--> statement-breakpoint +CREATE INDEX "competitor_snapshots_source_idx" ON "competitor_snapshots" USING btree ("source_id","captured_at");--> statement-breakpoint +CREATE INDEX "competitor_sources_company_idx" ON "competitor_sources" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "competitor_sources_competitor_idx" ON "competitor_sources" USING btree ("competitor_id");--> statement-breakpoint +CREATE INDEX "competitors_company_idx" ON "competitors" USING btree ("company_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0015_snapshot.json b/packages/db/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..e1f77ee9 --- /dev/null +++ b/packages/db/drizzle/meta/0015_snapshot.json @@ -0,0 +1,8816 @@ +{ + "id": "b5af6d6c-18e5-41c8-a653-6af8fa6c358a", + "prevId": "ba233426-f52e-4c4f-9a20-e14d97e5dc5a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_provider_account_id_pk": { + "name": "accounts_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_backup_codes": { + "name": "two_factor_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_four": { + "name": "last_four", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_tokens_hash_idx": { + "name": "api_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_user_company_idx": { + "name": "api_tokens_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_company_idx": { + "name": "api_tokens_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_tokens_company_id_companies_id_fk": { + "name": "api_tokens_company_id_companies_id_fk", + "tableFrom": "api_tokens", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "company_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pre_seed'" + }, + "business_model": { + "name": "business_model", + "type": "business_model", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'saas'" + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "founded_date": { + "name": "founded_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "fiscal_year_end": { + "name": "fiscal_year_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en-US'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/New_York'" + }, + "region": { + "name": "region", + "type": "data_region", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'us-east'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_provider": { + "name": "billing_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_subscription_id": { + "name": "billing_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_plan": { + "name": "billing_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'free'" + }, + "benefits_rates": { + "name": "benefits_rates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "founders_ownership_percent": { + "name": "founders_ownership_percent", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": true, + "default": "'100.0000'" + }, + "mcp_server_enabled": { + "name": "mcp_server_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "companies_owner_id_users_id_fk": { + "name": "companies_owner_id_users_id_fk", + "tableFrom": "companies", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_members": { + "name": "company_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_member_unique": { + "name": "company_member_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_member_user_idx": { + "name": "company_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_members_company_id_companies_id_fk": { + "name": "company_members_company_id_companies_id_fk", + "tableFrom": "company_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_members_user_id_users_id_fk": { + "name": "company_members_user_id_users_id_fk", + "tableFrom": "company_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_company_idx": { + "name": "departments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_company_id_companies_id_fk": { + "name": "departments_company_id_companies_id_fk", + "tableFrom": "departments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_code_redemptions": { + "name": "invite_code_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_code_id": { + "name": "invite_code_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_redemptions_code_idx": { + "name": "invite_redemptions_code_idx", + "columns": [ + { + "expression": "invite_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_redemptions_user_code_idx": { + "name": "invite_redemptions_user_code_idx", + "columns": [ + { + "expression": "invite_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_code_redemptions_invite_code_id_invite_codes_id_fk": { + "name": "invite_code_redemptions_invite_code_id_invite_codes_id_fk", + "tableFrom": "invite_code_redemptions", + "tableTo": "invite_codes", + "columnsFrom": [ + "invite_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_code_redemptions_user_id_users_id_fk": { + "name": "invite_code_redemptions_user_id_users_id_fk", + "tableFrom": "invite_code_redemptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_codes": { + "name": "invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "invite_code_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'single_use'" + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_redemptions": { + "name": "current_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "free_platform_days": { + "name": "free_platform_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "ai_credits_cents": { + "name": "ai_credits_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_codes_code_idx": { + "name": "invite_codes_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_created_by_idx": { + "name": "invite_codes_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_active_idx": { + "name": "invite_codes_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_codes_created_by_users_id_fk": { + "name": "invite_codes_created_by_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_codes": { + "name": "oauth_auth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_auth_codes_hash_idx": { + "name": "oauth_auth_codes_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_codes_client_id_oauth_clients_id_fk": { + "name": "oauth_auth_codes_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_auth_codes_user_id_users_id_fk": { + "name": "oauth_auth_codes_user_id_users_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_auth_codes_company_id_companies_id_fk": { + "name": "oauth_auth_codes_company_id_companies_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_expires_at": { + "name": "access_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_tokens_access_hash_idx": { + "name": "oauth_tokens_access_hash_idx", + "columns": [ + { + "expression": "access_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_refresh_hash_idx": { + "name": "oauth_tokens_refresh_hash_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_grant_idx": { + "name": "oauth_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_user_company_idx": { + "name": "oauth_tokens_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_tokens_client_id_oauth_clients_id_fk": { + "name": "oauth_tokens_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_company_id_companies_id_fk": { + "name": "oauth_tokens_company_id_companies_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_credentials": { + "name": "integration_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "integration_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_credentials_company_type_idx": { + "name": "integration_credentials_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_credentials_company_id_companies_id_fk": { + "name": "integration_credentials_company_id_companies_id_fk", + "tableFrom": "integration_credentials", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integration_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integration_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integrations_company_type_idx": { + "name": "integrations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrations_company_id_companies_id_fk": { + "name": "integrations_company_id_companies_id_fk", + "tableFrom": "integrations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "mcp_owner_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "mcp_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "mcp_auth_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "status": { + "name": "status", + "type": "mcp_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_company_idx": { + "name": "mcp_connections_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_owner_idx": { + "name": "mcp_connections_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_company_name_idx": { + "name": "mcp_connections_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_company_slug_idx": { + "name": "mcp_connections_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_company_id_companies_id_fk": { + "name": "mcp_connections_company_id_companies_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_connections_owner_user_id_users_id_fk": { + "name": "mcp_connections_owner_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_connections_personal_owner_check": { + "name": "mcp_connections_personal_owner_check", + "value": "(owner_scope = 'personal') = (owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mcp_credentials": { + "name": "mcp_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "mcp_auth_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_registration": { + "name": "client_registration", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_credentials_connection_idx": { + "name": "mcp_credentials_connection_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_credentials_mcp_connection_id_mcp_connections_id_fk": { + "name": "mcp_credentials_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_credentials", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_prefs": { + "name": "mcp_tool_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "perm_class_override": { + "name": "perm_class_override", + "type": "mcp_tool_perm", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_tool_prefs_connection_tool_idx": { + "name": "mcp_tool_prefs_connection_tool_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_prefs_mcp_connection_id_mcp_connections_id_fk": { + "name": "mcp_tool_prefs_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_tool_prefs", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_preferences": { + "name": "dashboard_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "dashboard_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dynamic'" + }, + "hero_cards": { + "name": "hero_cards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "secondary_metrics": { + "name": "secondary_metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "card_mode_overrides": { + "name": "card_mode_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "card_scenario_overrides": { + "name": "card_scenario_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "custom_slug_overrides": { + "name": "custom_slug_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "slot_overrides": { + "name": "slot_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_metrics": { + "name": "custom_metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "closed_widgets": { + "name": "closed_widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "page_layouts": { + "name": "page_layouts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboard_prefs_user_company_idx": { + "name": "dashboard_prefs_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_preferences_user_id_users_id_fk": { + "name": "dashboard_preferences_user_id_users_id_fk", + "tableFrom": "dashboard_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_preferences_company_id_companies_id_fk": { + "name": "dashboard_preferences_company_id_companies_id_fk", + "tableFrom": "dashboard_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_logs": { + "name": "export_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "export_type": { + "name": "export_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "export_logs_company_idx": { + "name": "export_logs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "export_logs_company_created_idx": { + "name": "export_logs_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "export_logs_user_idx": { + "name": "export_logs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "export_logs_company_id_companies_id_fk": { + "name": "export_logs_company_id_companies_id_fk", + "tableFrom": "export_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "export_logs_user_id_users_id_fk": { + "name": "export_logs_user_id_users_id_fk", + "tableFrom": "export_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "notification_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_unread_idx": { + "name": "notifications_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_company_id_companies_id_fk": { + "name": "notifications_company_id_companies_id_fk", + "tableFrom": "notifications", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privacy_consents": { + "name": "privacy_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "consent_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "granted": { + "name": "granted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "privacy_consents_user_idx": { + "name": "privacy_consents_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "privacy_consents_user_purpose_idx": { + "name": "privacy_consents_user_purpose_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "privacy_consents_user_id_users_id_fk": { + "name": "privacy_consents_user_id_users_id_fk", + "tableFrom": "privacy_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_job_runs": { + "name": "scheduled_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scheduled_job_id": { + "name": "scheduled_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "scheduled_job_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "scheduled_job_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scheduled_job_runs_job_idx": { + "name": "scheduled_job_runs_job_idx", + "columns": [ + { + "expression": "scheduled_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_job_runs_company_idx": { + "name": "scheduled_job_runs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_job_runs_scheduled_job_id_scheduled_jobs_id_fk": { + "name": "scheduled_job_runs_scheduled_job_id_scheduled_jobs_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "scheduled_jobs", + "columnsFrom": [ + "scheduled_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_job_runs_company_id_companies_id_fk": { + "name": "scheduled_job_runs_company_id_companies_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_jobs": { + "name": "scheduled_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_kind": { + "name": "action_kind", + "type": "scheduled_job_action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "bound_connection_ids": { + "name": "bound_connection_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "scheduled_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notify_policy": { + "name": "notify_policy", + "type": "scheduled_job_notify_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'smart'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_cursor": { + "name": "last_run_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scheduled_jobs_company_idx": { + "name": "scheduled_jobs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_jobs_due_idx": { + "name": "scheduled_jobs_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_jobs_company_id_companies_id_fk": { + "name": "scheduled_jobs_company_id_companies_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_jobs_created_by_user_id_users_id_fk": { + "name": "scheduled_jobs_created_by_user_id_users_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidebar_order": { + "name": "sidebar_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quick_action_mode": { + "name": "quick_action_mode", + "type": "quick_action_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dynamic'" + }, + "quick_action_mode_overrides": { + "name": "quick_action_mode_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_quick_actions": { + "name": "custom_quick_actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sidebar_collapsed": { + "name": "sidebar_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled_mcp_connections": { + "name": "disabled_mcp_connections", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_builtin_tools": { + "name": "disabled_builtin_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_preferences_user_company_idx": { + "name": "user_preferences_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_company_id_companies_id_fk": { + "name": "user_preferences_company_id_companies_id_fk", + "tableFrom": "user_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weekly_digests": { + "name": "weekly_digests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "narrative": { + "name": "narrative", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deterministic_summary": { + "name": "deterministic_summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_sent_at": { + "name": "email_sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "weekly_digests_company_idx": { + "name": "weekly_digests_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "weekly_digests_company_week_idx": { + "name": "weekly_digests_company_week_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weekly_digests_company_id_companies_id_fk": { + "name": "weekly_digests_company_id_companies_id_fk", + "tableFrom": "weekly_digests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_conversations": { + "name": "ai_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_grants": { + "name": "session_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "session_disabled_tools": { + "name": "session_disabled_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_conversations_company_idx": { + "name": "ai_conversations_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_conversations_company_user_idx": { + "name": "ai_conversations_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversations_company_id_companies_id_fk": { + "name": "ai_conversations_company_id_companies_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_feature_flags": { + "name": "ai_feature_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "master_enabled": { + "name": "master_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "data_mode": { + "name": "data_mode", + "type": "ai_data_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "monthly_budget_cents": { + "name": "monthly_budget_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"onboarding\":true,\"chat\":true,\"insights\":true,\"uiPersonalization\":true,\"autoCategorization\":true,\"weeklyDigest\":true}'::jsonb" + }, + "write_mode": { + "name": "write_mode", + "type": "ai_write_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'confirm'" + }, + "companion_name": { + "name": "companion_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Companion'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_feature_flags_company_idx": { + "name": "ai_feature_flags_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_feature_flags_company_id_companies_id_fk": { + "name": "ai_feature_flags_company_id_companies_id_fk", + "tableFrom": "ai_feature_flags", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_insight_cache": { + "name": "ai_insight_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ai_insight_cache_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stale_reason": { + "name": "stale_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_insight_cache_company_idx": { + "name": "ai_insight_cache_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_insight_cache_company_key_idx": { + "name": "ai_insight_cache_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_insight_cache_company_id_companies_id_fk": { + "name": "ai_insight_cache_company_id_companies_id_fk", + "tableFrom": "ai_insight_cache", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_permission_defaults": { + "name": "ai_permission_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_mode": { + "name": "read_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "write_mode": { + "name": "write_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "delete_mode": { + "name": "delete_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "web_search_mode": { + "name": "web_search_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "browser_use_mode": { + "name": "browser_use_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_permission_defaults_user_company_idx": { + "name": "ai_permission_defaults_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_permission_defaults_user_id_users_id_fk": { + "name": "ai_permission_defaults_user_id_users_id_fk", + "tableFrom": "ai_permission_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_permission_defaults_company_id_companies_id_fk": { + "name": "ai_permission_defaults_company_id_companies_id_fk", + "tableFrom": "ai_permission_defaults", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_provider_models": { + "name": "ai_provider_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supports_tools": { + "name": "supports_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "supports_images": { + "name": "supports_images", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ai_provider_model_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_provider_models_provider_idx": { + "name": "ai_provider_models_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_provider_models_provider_model_idx": { + "name": "ai_provider_models_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_provider_models_provider_id_ai_providers_id_fk": { + "name": "ai_provider_models_provider_id_ai_providers_id_fk", + "tableFrom": "ai_provider_models", + "tableTo": "ai_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_providers": { + "name": "ai_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "ai_provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_mode": { + "name": "api_key_mode", + "type": "ai_api_key_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user_provided'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drop_params": { + "name": "drop_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_providers_company_idx": { + "name": "ai_providers_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_providers_company_id_companies_id_fk": { + "name": "ai_providers_company_id_companies_id_fk", + "tableFrom": "ai_providers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_tool_audit_logs": { + "name": "ai_tool_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_job_run_id": { + "name": "scheduled_job_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ai_tool_audit_log_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permission_decision": { + "name": "permission_decision", + "type": "ai_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_info": { + "name": "client_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_tool_audit_company_idx": { + "name": "ai_tool_audit_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_user_idx": { + "name": "ai_tool_audit_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_created_idx": { + "name": "ai_tool_audit_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_tool_idx": { + "name": "ai_tool_audit_tool_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_conversation_idx": { + "name": "ai_tool_audit_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_mcp_connection_idx": { + "name": "ai_tool_audit_mcp_connection_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_scheduled_job_run_idx": { + "name": "ai_tool_audit_scheduled_job_run_idx", + "columns": [ + { + "expression": "scheduled_job_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_tool_audit_logs_company_id_companies_id_fk": { + "name": "ai_tool_audit_logs_company_id_companies_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_user_id_users_id_fk": { + "name": "ai_tool_audit_logs_user_id_users_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_conversation_id_ai_conversations_id_fk": { + "name": "ai_tool_audit_logs_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_mcp_connection_id_mcp_connections_id_fk": { + "name": "ai_tool_audit_logs_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_scheduled_job_run_id_scheduled_job_runs_id_fk": { + "name": "ai_tool_audit_logs_scheduled_job_run_id_scheduled_job_runs_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "scheduled_job_runs", + "columnsFrom": [ + "scheduled_job_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_turn_events": { + "name": "ai_turn_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ai_turn_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_turn_events_conversation_seq_idx": { + "name": "ai_turn_events_conversation_seq_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_turn_events_open_gate_idx": { + "name": "ai_turn_events_open_gate_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_turn_events\".\"type\" = 'gate' AND \"ai_turn_events\".\"resolved_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_turn_events_conversation_id_ai_conversations_id_fk": { + "name": "ai_turn_events_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_turn_events", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage_logs": { + "name": "ai_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_company_idx": { + "name": "ai_usage_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_feature_idx": { + "name": "ai_usage_feature_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_created_idx": { + "name": "ai_usage_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_logs_company_id_companies_id_fk": { + "name": "ai_usage_logs_company_id_companies_id_fk", + "tableFrom": "ai_usage_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insight_invalidations": { + "name": "insight_invalidations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "insight_type": { + "name": "insight_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mutation_source": { + "name": "mutation_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_invalidated_at": { + "name": "first_invalidated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_mutation_at": { + "name": "last_mutation_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "insight_invalidations_company_type_idx": { + "name": "insight_invalidations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "insight_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insight_invalidations_pending_idx": { + "name": "insight_invalidations_pending_idx", + "columns": [ + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insight_invalidations_company_id_companies_id_fk": { + "name": "insight_invalidations_company_id_companies_id_fk", + "tableFrom": "insight_invalidations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_only": { + "name": "read_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_company_idx": { + "name": "memory_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_company_domain_kind_idx": { + "name": "memory_company_domain_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_company_tier_idx": { + "name": "memory_company_tier_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_embedding_hnsw": { + "name": "memory_embedding_hnsw", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "where": "\"memory\".\"embedding\" IS NOT NULL", + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "memory_company_id_companies_id_fk": { + "name": "memory_company_id_companies_id_fk", + "tableFrom": "memory", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_user_id_users_id_fk": { + "name": "memory_user_id_users_id_fk", + "tableFrom": "memory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bonuses": { + "name": "bonuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout_month": { + "name": "payout_month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "bonus_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'performance'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bonuses_company_idx": { + "name": "bonuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bonuses_headcount_month_idx": { + "name": "bonuses_headcount_month_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payout_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bonuses_company_id_companies_id_fk": { + "name": "bonuses_company_id_companies_id_fk", + "tableFrom": "bonuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bonuses_headcount_id_headcount_plans_id_fk": { + "name": "bonuses_headcount_id_headcount_plans_id_fk", + "tableFrom": "bonuses", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.equity_grants": { + "name": "equity_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_date": { + "name": "grant_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": true + }, + "strike_price": { + "name": "strike_price", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": false + }, + "grant_type": { + "name": "grant_type", + "type": "equity_grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'iso'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "equity_grants_company_idx": { + "name": "equity_grants_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "equity_grants_headcount_idx": { + "name": "equity_grants_headcount_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "equity_grants_company_id_companies_id_fk": { + "name": "equity_grants_company_id_companies_id_fk", + "tableFrom": "equity_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "equity_grants_headcount_id_headcount_plans_id_fk": { + "name": "equity_grants_headcount_id_headcount_plans_id_fk", + "tableFrom": "equity_grants", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_accounts": { + "name": "financial_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "account_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "covers_headcount": { + "name": "covers_headcount", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_accounts_company_idx": { + "name": "financial_accounts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_accounts_parent_idx": { + "name": "financial_accounts_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "financial_accounts_company_id_companies_id_fk": { + "name": "financial_accounts_company_id_companies_id_fk", + "tableFrom": "financial_accounts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_audit_logs": { + "name": "financial_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "audit_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_audit_company_idx": { + "name": "financial_audit_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_entity_idx": { + "name": "financial_audit_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_user_idx": { + "name": "financial_audit_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_created_idx": { + "name": "financial_audit_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "financial_audit_logs_company_id_companies_id_fk": { + "name": "financial_audit_logs_company_id_companies_id_fk", + "tableFrom": "financial_audit_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "financial_audit_logs_user_id_users_id_fk": { + "name": "financial_audit_logs_user_id_users_id_fk", + "tableFrom": "financial_audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forecast_lines": { + "name": "forecast_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "forecast_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "expense_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + }, + "is_one_time": { + "name": "is_one_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forecast_lines_company_idx": { + "name": "forecast_lines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_account_idx": { + "name": "forecast_lines_company_account_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_department_idx": { + "name": "forecast_lines_company_department_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_vendor_idx": { + "name": "forecast_lines_vendor_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_name_idx": { + "name": "forecast_lines_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"forecast_lines\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forecast_lines_company_id_companies_id_fk": { + "name": "forecast_lines_company_id_companies_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forecast_lines_account_id_financial_accounts_id_fk": { + "name": "forecast_lines_account_id_financial_accounts_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forecast_lines_department_id_departments_id_fk": { + "name": "forecast_lines_department_id_departments_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forecast_values": { + "name": "forecast_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "forecast_line_id": { + "name": "forecast_line_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "is_override": { + "name": "is_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forecast_values_line_idx": { + "name": "forecast_values_line_idx", + "columns": [ + { + "expression": "forecast_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_values_month_idx": { + "name": "forecast_values_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_values_line_month_idx": { + "name": "forecast_values_line_month_idx", + "columns": [ + { + "expression": "forecast_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forecast_values_forecast_line_id_forecast_lines_id_fk": { + "name": "forecast_values_forecast_line_id_forecast_lines_id_fk", + "tableFrom": "forecast_values", + "tableTo": "forecast_lines", + "columnsFrom": [ + "forecast_line_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_round_investors": { + "name": "funding_round_investors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "funding_round_id": { + "name": "funding_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_invested": { + "name": "amount_invested", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_round_investors_round_idx": { + "name": "funding_round_investors_round_idx", + "columns": [ + { + "expression": "funding_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_round_investors_funding_round_id_funding_rounds_id_fk": { + "name": "funding_round_investors_funding_round_id_funding_rounds_id_fk", + "tableFrom": "funding_round_investors", + "tableTo": "funding_rounds", + "columnsFrom": [ + "funding_round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_rounds": { + "name": "funding_rounds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "funding_round_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "pre_money_valuation": { + "name": "pre_money_valuation", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": false + }, + "dilution_percent": { + "name": "dilution_percent", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": false + }, + "is_projected": { + "name": "is_projected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "close_date": { + "name": "close_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_rounds_company_idx": { + "name": "funding_rounds_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_rounds_company_id_companies_id_fk": { + "name": "funding_rounds_company_id_companies_id_fk", + "tableFrom": "funding_rounds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.headcount_plans": { + "name": "headcount_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employee_type": { + "name": "employee_type", + "type": "headcount_employee_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full_time'" + }, + "count": { + "name": "count", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1.00'" + }, + "salary": { + "name": "salary", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "hourly_rate": { + "name": "hourly_rate", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "hours_per_week": { + "name": "hours_per_week", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "benefits_rate": { + "name": "benefits_rate", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0.20'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "headcount_plans_company_idx": { + "name": "headcount_plans_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "headcount_plans_department_idx": { + "name": "headcount_plans_department_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "headcount_plans_company_id_companies_id_fk": { + "name": "headcount_plans_company_id_companies_id_fk", + "tableFrom": "headcount_plans", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "headcount_plans_department_id_departments_id_fk": { + "name": "headcount_plans_department_id_departments_id_fk", + "tableFrom": "headcount_plans", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_batches": { + "name": "import_batches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_batch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_rows": { + "name": "total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "column_mapping": { + "name": "column_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "import_batches_company_idx": { + "name": "import_batches_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "import_batches_account_idx": { + "name": "import_batches_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_batches_company_id_companies_id_fk": { + "name": "import_batches_company_id_companies_id_fk", + "tableFrom": "import_batches", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "import_batches_account_id_financial_accounts_id_fk": { + "name": "import_batches_account_id_financial_accounts_id_fk", + "tableFrom": "import_batches", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merchant_category_mappings": { + "name": "merchant_category_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merchant_pattern": { + "name": "merchant_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "account_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user_override'" + }, + "override_count": { + "name": "override_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchant_mappings_company_idx": { + "name": "merchant_mappings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_pattern_idx": { + "name": "merchant_mappings_pattern_idx", + "columns": [ + { + "expression": "merchant_pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_account_idx": { + "name": "merchant_mappings_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_company_pattern_idx": { + "name": "merchant_mappings_company_pattern_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "merchant_pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchant_category_mappings_company_id_companies_id_fk": { + "name": "merchant_category_mappings_company_id_companies_id_fk", + "tableFrom": "merchant_category_mappings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "merchant_category_mappings_account_id_financial_accounts_id_fk": { + "name": "merchant_category_mappings_account_id_financial_accounts_id_fk", + "tableFrom": "merchant_category_mappings", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metrics": { + "name": "metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formula": { + "name": "formula", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category": { + "name": "category", + "type": "metric_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'financial'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "metrics_company_slug_idx": { + "name": "metrics_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metrics_company_id_companies_id_fk": { + "name": "metrics_company_id_companies_id_fk", + "tableFrom": "metrics", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.option_pools": { + "name": "option_pools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_reserved": { + "name": "total_reserved", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true + }, + "refresh_date": { + "name": "refresh_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "option_pools_company_idx": { + "name": "option_pools_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "option_pools_company_id_companies_id_fk": { + "name": "option_pools_company_id_companies_id_fk", + "tableFrom": "option_pools", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revenue_streams": { + "name": "revenue_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "revenue_stream_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'subscription'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "revenue_streams_company_idx": { + "name": "revenue_streams_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "revenue_streams_active_idx": { + "name": "revenue_streams_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "end_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "revenue_streams_company_id_companies_id_fk": { + "name": "revenue_streams_company_id_companies_id_fk", + "tableFrom": "revenue_streams", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.salary_changes": { + "name": "salary_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "new_salary": { + "name": "new_salary", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "salary_changes_company_idx": { + "name": "salary_changes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "salary_changes_headcount_date_idx": { + "name": "salary_changes_headcount_date_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "salary_changes_company_id_companies_id_fk": { + "name": "salary_changes_company_id_companies_id_fk", + "tableFrom": "salary_changes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "salary_changes_headcount_id_headcount_plans_id_fk": { + "name": "salary_changes_headcount_id_headcount_plans_id_fk", + "tableFrom": "salary_changes", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenario_overrides": { + "name": "scenario_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "scenario_override_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "original_data": { + "name": "original_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scenario_overrides_unique": { + "name": "scenario_overrides_unique", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scenario_overrides_scenario_type": { + "name": "scenario_overrides_scenario_type", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scenario_overrides_scenario_id_scenarios_id_fk": { + "name": "scenario_overrides_scenario_id_scenarios_id_fk", + "tableFrom": "scenario_overrides", + "tableTo": "scenarios", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenarios": { + "name": "scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "scenario_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'blank'" + }, + "status": { + "name": "status", + "type": "scenario_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_scenario_id": { + "name": "source_scenario_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_conversation_id": { + "name": "ai_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_delete_at": { + "name": "auto_delete_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scenarios_company_idx": { + "name": "scenarios_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scenarios_company_id_companies_id_fk": { + "name": "scenarios_company_id_companies_id_fk", + "tableFrom": "scenarios", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_classes": { + "name": "share_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "class_type": { + "name": "class_type", + "type": "share_class_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "total_authorized": { + "name": "total_authorized", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true + }, + "total_issued": { + "name": "total_issued", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "par_value": { + "name": "par_value", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0.000001'" + }, + "liquidation_preference": { + "name": "liquidation_preference", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "share_classes_company_idx": { + "name": "share_classes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_classes_company_id_companies_id_fk": { + "name": "share_classes_company_id_companies_id_fk", + "tableFrom": "share_classes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "transaction_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_batch_id": { + "name": "import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transactions_company_date_idx": { + "name": "transactions_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_account_idx": { + "name": "transactions_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_external_id_idx": { + "name": "transactions_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_batch_idx": { + "name": "transactions_batch_idx", + "columns": [ + { + "expression": "import_batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_company_id_companies_id_fk": { + "name": "transactions_company_id_companies_id_fk", + "tableFrom": "transactions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_account_id_financial_accounts_id_fk": { + "name": "transactions_account_id_financial_accounts_id_fk", + "tableFrom": "transactions", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_changes": { + "name": "competitor_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_changes_company_idx": { + "name": "competitor_changes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_changes_competitor_idx": { + "name": "competitor_changes_competitor_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_changes_competitor_id_competitors_id_fk": { + "name": "competitor_changes_competitor_id_competitors_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_source_id_competitor_sources_id_fk": { + "name": "competitor_changes_source_id_competitor_sources_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitor_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_snapshot_id_competitor_snapshots_id_fk": { + "name": "competitor_changes_snapshot_id_competitor_snapshots_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitor_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_company_id_companies_id_fk": { + "name": "competitor_changes_company_id_companies_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_snapshots": { + "name": "competitor_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_hash": { + "name": "raw_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "structured": { + "name": "structured", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "structured_hash": { + "name": "structured_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_snapshots_source_idx": { + "name": "competitor_snapshots_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_snapshots_competitor_id_competitors_id_fk": { + "name": "competitor_snapshots_competitor_id_competitors_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_snapshots_source_id_competitor_sources_id_fk": { + "name": "competitor_snapshots_source_id_competitor_sources_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "competitor_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_snapshots_company_id_companies_id_fk": { + "name": "competitor_snapshots_company_id_companies_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_sources": { + "name": "competitor_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_hours": { + "name": "interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 168 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_state": { + "name": "health_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ok'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_sources_company_idx": { + "name": "competitor_sources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_sources_competitor_idx": { + "name": "competitor_sources_competitor_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_sources_competitor_id_competitors_id_fk": { + "name": "competitor_sources_competitor_id_competitors_id_fk", + "tableFrom": "competitor_sources", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_sources_company_id_companies_id_fk": { + "name": "competitor_sources_company_id_companies_id_fk", + "tableFrom": "competitor_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitors": { + "name": "competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitors_company_idx": { + "name": "competitors_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitors_company_id_companies_id_fk": { + "name": "competitors_company_id_companies_id_fk", + "tableFrom": "competitors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.business_model": { + "name": "business_model", + "schema": "public", + "values": [ + "saas", + "marketplace", + "ecommerce", + "services", + "hardware", + "other" + ] + }, + "public.company_stage": { + "name": "company_stage", + "schema": "public", + "values": [ + "pre_seed", + "seed", + "series_a", + "series_b", + "series_c_plus", + "bootstrapped" + ] + }, + "public.data_region": { + "name": "data_region", + "schema": "public", + "values": [ + "us-east", + "eu-west", + "ap-south" + ] + }, + "public.invite_code_type": { + "name": "invite_code_type", + "schema": "public", + "values": [ + "single_use", + "multi_use" + ] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "editor", + "viewer" + ] + }, + "public.integration_status": { + "name": "integration_status", + "schema": "public", + "values": [ + "active", + "disconnected", + "error" + ] + }, + "public.integration_type": { + "name": "integration_type", + "schema": "public", + "values": [ + "quickbooks", + "xero", + "freshbooks", + "plaid", + "mercury", + "gusto", + "stripe" + ] + }, + "public.mcp_auth_type": { + "name": "mcp_auth_type", + "schema": "public", + "values": [ + "oauth", + "pat", + "none" + ] + }, + "public.mcp_connection_status": { + "name": "mcp_connection_status", + "schema": "public", + "values": [ + "pending", + "connected", + "needs_auth", + "error", + "disabled" + ] + }, + "public.mcp_owner_scope": { + "name": "mcp_owner_scope", + "schema": "public", + "values": [ + "company", + "personal" + ] + }, + "public.mcp_tool_perm": { + "name": "mcp_tool_perm", + "schema": "public", + "values": [ + "read", + "write", + "delete" + ] + }, + "public.mcp_transport": { + "name": "mcp_transport", + "schema": "public", + "values": [ + "streamable_http", + "stdio" + ] + }, + "public.consent_purpose": { + "name": "consent_purpose", + "schema": "public", + "values": [ + "data_processing", + "ai_features", + "marketing", + "analytics" + ] + }, + "public.dashboard_mode": { + "name": "dashboard_mode", + "schema": "public", + "values": [ + "intelligence", + "dynamic", + "custom" + ] + }, + "public.notification_severity": { + "name": "notification_severity", + "schema": "public", + "values": [ + "info", + "success", + "warning", + "error" + ] + }, + "public.quick_action_mode": { + "name": "quick_action_mode", + "schema": "public", + "values": [ + "intelligence", + "dynamic", + "custom" + ] + }, + "public.scheduled_job_action_kind": { + "name": "scheduled_job_action_kind", + "schema": "public", + "values": [ + "write", + "notify" + ] + }, + "public.scheduled_job_notify_policy": { + "name": "scheduled_job_notify_policy", + "schema": "public", + "values": [ + "smart", + "failures", + "every", + "off" + ] + }, + "public.scheduled_job_run_status": { + "name": "scheduled_job_run_status", + "schema": "public", + "values": [ + "running", + "success", + "failed", + "missed" + ] + }, + "public.scheduled_job_run_trigger": { + "name": "scheduled_job_run_trigger", + "schema": "public", + "values": [ + "schedule", + "manual", + "dry_run" + ] + }, + "public.scheduled_job_status": { + "name": "scheduled_job_status", + "schema": "public", + "values": [ + "active", + "disabled", + "auto_disabled", + "error" + ] + }, + "public.ai_api_key_mode": { + "name": "ai_api_key_mode", + "schema": "public", + "values": [ + "managed", + "user_provided", + "none" + ] + }, + "public.ai_data_mode": { + "name": "ai_data_mode", + "schema": "public", + "values": [ + "full", + "show_cached", + "hide_all" + ] + }, + "public.ai_insight_cache_type": { + "name": "ai_insight_cache_type", + "schema": "public", + "values": [ + "dashboard", + "revenue", + "expense", + "scenario", + "funding", + "team", + "reports", + "general" + ] + }, + "public.ai_permission_mode": { + "name": "ai_permission_mode", + "schema": "public", + "values": [ + "ask", + "session", + "always" + ] + }, + "public.ai_provider_kind": { + "name": "ai_provider_kind", + "schema": "public", + "values": [ + "anthropic", + "openai", + "openrouter", + "ollama", + "google", + "mistral", + "groq", + "openai-compatible" + ] + }, + "public.ai_provider_model_source": { + "name": "ai_provider_model_source", + "schema": "public", + "values": [ + "fetched", + "manual", + "preset" + ] + }, + "public.ai_tool_audit_log_status": { + "name": "ai_tool_audit_log_status", + "schema": "public", + "values": [ + "success", + "error", + "validation_error", + "pending_apply" + ] + }, + "public.ai_tool_permission_decision": { + "name": "ai_tool_permission_decision", + "schema": "public", + "values": [ + "auto", + "granted_once", + "granted_session", + "denied" + ] + }, + "public.ai_turn_event_type": { + "name": "ai_turn_event_type", + "schema": "public", + "values": [ + "user_message", + "assistant_step", + "tool_result", + "scenario", + "gate", + "turn_done", + "turn_error" + ] + }, + "public.ai_write_mode": { + "name": "ai_write_mode", + "schema": "public", + "values": [ + "full", + "confirm", + "read_only" + ] + }, + "public.account_category": { + "name": "account_category", + "schema": "public", + "values": [ + "revenue", + "cogs", + "operating_expense", + "other_income", + "other_expense", + "asset", + "liability", + "equity" + ] + }, + "public.account_type": { + "name": "account_type", + "schema": "public", + "values": [ + "income", + "expense", + "asset", + "liability", + "equity" + ] + }, + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "import", + "rollback" + ] + }, + "public.audit_entity_type": { + "name": "audit_entity_type", + "schema": "public", + "values": [ + "transaction", + "financial_account", + "scenario", + "forecast_line", + "forecast_value", + "headcount_plan", + "revenue_stream", + "funding_round", + "import_batch", + "department", + "metric", + "salary_change", + "bonus", + "equity_grant", + "funding_round_investor", + "share_class", + "option_pool" + ] + }, + "public.bonus_type": { + "name": "bonus_type", + "schema": "public", + "values": [ + "signing", + "performance", + "retention", + "other" + ] + }, + "public.equity_grant_type": { + "name": "equity_grant_type", + "schema": "public", + "values": [ + "iso", + "nso", + "rsu" + ] + }, + "public.expense_frequency": { + "name": "expense_frequency", + "schema": "public", + "values": [ + "monthly", + "quarterly", + "annual" + ] + }, + "public.forecast_method": { + "name": "forecast_method", + "schema": "public", + "values": [ + "fixed", + "growth_rate", + "per_unit", + "percentage_of", + "custom_formula" + ] + }, + "public.funding_round_type": { + "name": "funding_round_type", + "schema": "public", + "values": [ + "pre_seed", + "seed", + "series_a", + "series_b", + "series_c_plus", + "debt", + "grant", + "safe", + "convertible" + ] + }, + "public.headcount_employee_type": { + "name": "headcount_employee_type", + "schema": "public", + "values": [ + "full_time", + "part_time", + "contractor" + ] + }, + "public.import_batch_status": { + "name": "import_batch_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "rolled_back", + "failed" + ] + }, + "public.metric_category": { + "name": "metric_category", + "schema": "public", + "values": [ + "financial", + "saas", + "growth", + "efficiency", + "custom" + ] + }, + "public.revenue_stream_type": { + "name": "revenue_stream_type", + "schema": "public", + "values": [ + "subscription", + "one_time", + "usage_based", + "services", + "marketplace", + "ecommerce", + "hardware" + ] + }, + "public.scenario_override_action": { + "name": "scenario_override_action", + "schema": "public", + "values": [ + "create", + "modify", + "delete" + ] + }, + "public.scenario_source": { + "name": "scenario_source", + "schema": "public", + "values": [ + "blank", + "ai", + "template", + "clone", + "backup" + ] + }, + "public.scenario_status": { + "name": "scenario_status", + "schema": "public", + "values": [ + "active", + "promoted", + "archived" + ] + }, + "public.share_class_type": { + "name": "share_class_type", + "schema": "public", + "values": [ + "common", + "preferred" + ] + }, + "public.transaction_source": { + "name": "transaction_source", + "schema": "public", + "values": [ + "manual", + "import", + "integration", + "forecast" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4f88c920..4435f080 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1782459274352, "tag": "0014_curvy_shinobi_shaw", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1782709952361, + "tag": "0015_shocking_lord_tyger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c6507e5d..c71a89eb 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -5,4 +5,5 @@ export * from "./schema/platform"; export * from "./schema/ai"; export * from "./schema/memory"; export * from "./schema/finance"; +export * from "./schema/competitor"; export * from "./schema/relations"; diff --git a/packages/db/src/schema/competitor.ts b/packages/db/src/schema/competitor.ts new file mode 100644 index 00000000..2f094dce --- /dev/null +++ b/packages/db/src/schema/competitor.ts @@ -0,0 +1,134 @@ +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { companies } from "./tenant"; + +// ── Competitors ─────────────────────────────────────────────────────────────── + +export const competitors = pgTable( + "competitors", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + companyId: text("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + name: text("name").notNull(), + url: text("url").notNull(), + status: text("status").notNull().default("active"), // "active" | "paused" + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), + }, + (table) => [ + index("competitors_company_idx").on(table.companyId), + ] +); + +// ── Competitor Sources ──────────────────────────────────────────────────────── + +export const competitorSources = pgTable( + "competitor_sources", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + competitorId: text("competitor_id") + .notNull() + .references(() => competitors.id, { onDelete: "cascade" }), + companyId: text("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + type: text("type").notNull(), // "pricing" | "social" + url: text("url").notNull(), + config: jsonb("config"), + enabled: boolean("enabled").notNull().default(true), + intervalHours: integer("interval_hours").notNull().default(168), + lastRunAt: timestamp("last_run_at", { mode: "date" }), + lastStatus: text("last_status"), + healthState: text("health_state").notNull().default("ok"), // "ok" | "broken" + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), + }, + (table) => [ + index("competitor_sources_company_idx").on(table.companyId), + index("competitor_sources_competitor_idx").on(table.competitorId), + ] +); + +// ── Competitor Snapshots ────────────────────────────────────────────────────── + +export const competitorSnapshots = pgTable( + "competitor_snapshots", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + competitorId: text("competitor_id") + .notNull() + .references(() => competitors.id, { onDelete: "cascade" }), + sourceId: text("source_id") + .notNull() + .references(() => competitorSources.id, { onDelete: "cascade" }), + companyId: text("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + capturedAt: timestamp("captured_at", { mode: "date" }).defaultNow().notNull(), + raw: text("raw").notNull(), + rawHash: text("raw_hash").notNull(), + structured: jsonb("structured").notNull(), + structuredHash: text("structured_hash").notNull(), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + }, + (table) => [ + index("competitor_snapshots_source_idx").on(table.sourceId, table.capturedAt), + ] +); + +// ── Competitor Changes ──────────────────────────────────────────────────────── + +export const competitorChanges = pgTable( + "competitor_changes", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + competitorId: text("competitor_id") + .notNull() + .references(() => competitors.id, { onDelete: "cascade" }), + sourceId: text("source_id") + .notNull() + .references(() => competitorSources.id, { onDelete: "cascade" }), + snapshotId: text("snapshot_id") + .notNull() + .references(() => competitorSnapshots.id, { onDelete: "cascade" }), + companyId: text("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + detectedAt: timestamp("detected_at", { mode: "date" }).defaultNow().notNull(), + changeType: text("change_type").notNull(), + summary: text("summary").notNull(), + before: jsonb("before"), + after: jsonb("after"), + severity: text("severity").notNull().default("info"), + acknowledgedAt: timestamp("acknowledged_at", { mode: "date" }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + }, + (table) => [ + index("competitor_changes_company_idx").on(table.companyId, table.detectedAt), + index("competitor_changes_competitor_idx").on(table.competitorId), + ] +); + +// ── Inferred Row Types ──────────────────────────────────────────────────────── + +export type Competitor = typeof competitors.$inferSelect; +export type CompetitorSource = typeof competitorSources.$inferSelect; +export type CompetitorSnapshot = typeof competitorSnapshots.$inferSelect; +export type CompetitorChange = typeof competitorChanges.$inferSelect; From 7ddd9f4cddc7f899d6368a6742b5d079cc0f378e Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 10:49:22 +0530 Subject: [PATCH 04/55] feat(db): competitor queries + PGlite tests Company-scoped CRUD for competitors, sources, snapshots, and changes. getDueSources uses a JS-side filter (PGlite-safe interval arithmetic alternative). 7 tests, all green alongside the existing 250. Co-Authored-By: Claude Sonnet 4.6 --- packages/db/src/__tests__/competitor.test.ts | 118 +++++++++++ packages/db/src/queries/competitor.ts | 201 +++++++++++++++++++ packages/db/src/queries/index.ts | 18 ++ 3 files changed, 337 insertions(+) create mode 100644 packages/db/src/__tests__/competitor.test.ts create mode 100644 packages/db/src/queries/competitor.ts diff --git a/packages/db/src/__tests__/competitor.test.ts b/packages/db/src/__tests__/competitor.test.ts new file mode 100644 index 00000000..155de9cc --- /dev/null +++ b/packages/db/src/__tests__/competitor.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { getTestDb } from "./setup"; + +vi.mock("../index", () => ({ + get db() { + return getTestDb(); + }, +})); + +import { createUser, createCompany } from "./factories"; +import { + createCompetitor, + listCompetitors, + getCompetitor, + updateCompetitor, + deleteCompetitor, + createSource, + getDueSources, + getLatestSnapshot, + insertSnapshot, + insertChanges, + listChanges, +} from "../queries/competitor"; + +let companyId: string; + +beforeEach(async () => { + const owner = await createUser(); + const company = await createCompany(owner.id); + companyId = company.id; +}); + +describe("competitor queries", () => { + it("creates and lists competitors scoped by company", async () => { + await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const list = await listCompetitors(companyId); + expect(list.length).toBeGreaterThanOrEqual(1); + expect(list.some((c) => c.name === "Acme")).toBe(true); + }); + + it("getCompetitor returns undefined for wrong companyId", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const result = await getCompetitor(c.id, "wrong-company-id"); + expect(result).toBeUndefined(); + }); + + it("updateCompetitor patches name and is company-scoped", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const updated = await updateCompetitor(c.id, companyId, { name: "Acme Corp" }); + expect(updated?.name).toBe("Acme Corp"); + // wrong company returns undefined + const miss = await updateCompetitor(c.id, "other", { name: "X" }); + expect(miss).toBeUndefined(); + }); + + it("getDueSources returns sources never run or past their interval", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + await createSource({ + companyId, + competitorId: c.id, + type: "pricing", + url: "https://acme.com/pricing", + }); + const due = await getDueSources(new Date()); + // lastRunAt is null → due immediately + expect(due.length).toBeGreaterThanOrEqual(1); + }); + + it("insertSnapshot + getLatestSnapshot returns the most recent by source", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const s = await createSource({ companyId, competitorId: c.id, type: "pricing", url: "u" }); + await insertSnapshot({ + companyId, + competitorId: c.id, + sourceId: s.id, + raw: "a", + rawHash: "h1", + structured: { v: 1 }, + structuredHash: "sh1", + }); + const latest = await getLatestSnapshot(s.id); + expect(latest?.structuredHash).toBe("sh1"); + }); + + it("insertChanges + listChanges by company ordered by detectedAt desc", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const s = await createSource({ companyId, competitorId: c.id, type: "pricing", url: "u" }); + const snap = await insertSnapshot({ + companyId, + competitorId: c.id, + sourceId: s.id, + raw: "a", + rawHash: "h", + structured: {}, + structuredHash: "sh", + }); + await insertChanges([ + { + companyId, + competitorId: c.id, + sourceId: s.id, + snapshotId: snap.id, + changeType: "price_increase", + summary: "x", + severity: "warning", + }, + ]); + const changes = await listChanges(companyId); + expect(changes.length).toBeGreaterThanOrEqual(1); + expect(changes.some((ch) => ch.changeType === "price_increase")).toBe(true); + }); + + it("deleteCompetitor cascades and is company-scoped", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + await deleteCompetitor(c.id, companyId); + expect(await getCompetitor(c.id, companyId)).toBeUndefined(); + }); +}); diff --git a/packages/db/src/queries/competitor.ts b/packages/db/src/queries/competitor.ts new file mode 100644 index 00000000..1c7f6ce9 --- /dev/null +++ b/packages/db/src/queries/competitor.ts @@ -0,0 +1,201 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { db } from "../index"; +import { + competitors, + competitorSources, + competitorSnapshots, + competitorChanges, + type Competitor, + type CompetitorSource, + type CompetitorSnapshot, + type CompetitorChange, +} from "../schema/competitor"; + +// ── Competitors ─────────────────────────────────────────────────────────────── + +export async function createCompetitor(input: { + companyId: string; + name: string; + url: string; + status?: string; +}): Promise { + const [row] = await db.insert(competitors).values(input).returning(); + return row!; +} + +export async function listCompetitors(companyId: string): Promise { + return db + .select() + .from(competitors) + .where(eq(competitors.companyId, companyId)) + .orderBy(desc(competitors.createdAt)); +} + +export async function getCompetitor( + id: string, + companyId: string, +): Promise { + const [row] = await db + .select() + .from(competitors) + .where(and(eq(competitors.id, id), eq(competitors.companyId, companyId))); + return row; +} + +export async function updateCompetitor( + id: string, + companyId: string, + patch: Partial>, +): Promise { + const [row] = await db + .update(competitors) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(competitors.id, id), eq(competitors.companyId, companyId))) + .returning(); + return row; +} + +export async function deleteCompetitor(id: string, companyId: string): Promise { + await db + .delete(competitors) + .where(and(eq(competitors.id, id), eq(competitors.companyId, companyId))); +} + +// ── Competitor Sources ──────────────────────────────────────────────────────── + +export async function createSource(input: { + companyId: string; + competitorId: string; + type: string; + url: string; + config?: unknown; + intervalHours?: number; +}): Promise { + const [row] = await db + .insert(competitorSources) + .values(input as typeof competitorSources.$inferInsert) + .returning(); + return row!; +} + +export async function listSources( + competitorId: string, + companyId: string, +): Promise { + return db + .select() + .from(competitorSources) + .where( + and( + eq(competitorSources.competitorId, competitorId), + eq(competitorSources.companyId, companyId), + ), + ); +} + +export async function updateSource( + id: string, + companyId: string, + patch: Partial, +): Promise { + const [row] = await db + .update(competitorSources) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(competitorSources.id, id), eq(competitorSources.companyId, companyId))) + .returning(); + return row; +} + +export async function deleteSource(id: string, companyId: string): Promise { + await db + .delete(competitorSources) + .where(and(eq(competitorSources.id, id), eq(competitorSources.companyId, companyId))); +} + +/** + * Sources that are enabled and due to run: + * - never run (lastRunAt IS NULL), OR + * - lastRunAt + intervalHours hours <= now + * + * Uses a JS-side filter for PGlite compatibility (interval arithmetic in WHERE + * can behave inconsistently across PGlite versions). Correct for the expected + * scale of competitor sources (typically tens of rows, never thousands). + */ +export async function getDueSources(now: Date, limit = 200): Promise { + const rows = await db + .select() + .from(competitorSources) + .where(eq(competitorSources.enabled, true)); + + return rows + .filter( + (r) => + r.lastRunAt == null || + r.lastRunAt.getTime() + r.intervalHours * 3_600_000 <= now.getTime(), + ) + .slice(0, limit); +} + +// ── Competitor Snapshots ────────────────────────────────────────────────────── + +export async function getLatestSnapshot( + sourceId: string, +): Promise { + const [row] = await db + .select() + .from(competitorSnapshots) + .where(eq(competitorSnapshots.sourceId, sourceId)) + .orderBy(desc(competitorSnapshots.capturedAt)) + .limit(1); + return row; +} + +export async function insertSnapshot(input: { + companyId: string; + competitorId: string; + sourceId: string; + raw: string; + rawHash: string; + structured: unknown; + structuredHash: string; +}): Promise { + const [row] = await db + .insert(competitorSnapshots) + .values(input as typeof competitorSnapshots.$inferInsert) + .returning(); + return row!; +} + +// ── Competitor Changes ──────────────────────────────────────────────────────── + +export async function insertChanges( + rows: Array>, +): Promise { + if (rows.length === 0) return; + await db + .insert(competitorChanges) + .values(rows as typeof competitorChanges.$inferInsert[]); +} + +export async function listChanges( + companyId: string, + opts?: { competitorId?: string; limit?: number }, +): Promise { + const conds: ReturnType[] = [eq(competitorChanges.companyId, companyId)]; + if (opts?.competitorId) { + conds.push(eq(competitorChanges.competitorId, opts.competitorId)); + } + return db + .select() + .from(competitorChanges) + .where(conds.length === 1 ? conds[0] : and(...conds)) + .orderBy(desc(competitorChanges.detectedAt)) + .limit(opts?.limit ?? 100); +} + +export async function ackChange(id: string, companyId: string, at: Date): Promise { + await db + .update(competitorChanges) + .set({ acknowledgedAt: at }) + .where(and(eq(competitorChanges.id, id), eq(competitorChanges.companyId, companyId))); +} diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index dc905dbf..e24d46de 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -181,6 +181,24 @@ export { type OauthGrantSummary, } from "./oauth"; +export { + createCompetitor, + listCompetitors, + getCompetitor, + updateCompetitor, + deleteCompetitor, + createSource, + listSources, + updateSource, + deleteSource, + getDueSources, + getLatestSnapshot, + insertSnapshot, + insertChanges, + listChanges, + ackChange, +} from "./competitor"; + export { createScheduledJob, getScheduledJob, From 357f8520d3e79e855100d19e24c441b8f07f1b3a Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 10:55:03 +0530 Subject: [PATCH 05/55] fix(db): remove unused imports + tighten competitor query test assertions --- packages/db/src/__tests__/competitor.test.ts | 6 +++--- packages/db/src/queries/competitor.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/db/src/__tests__/competitor.test.ts b/packages/db/src/__tests__/competitor.test.ts index 155de9cc..e51dd274 100644 --- a/packages/db/src/__tests__/competitor.test.ts +++ b/packages/db/src/__tests__/competitor.test.ts @@ -34,7 +34,7 @@ describe("competitor queries", () => { it("creates and lists competitors scoped by company", async () => { await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); const list = await listCompetitors(companyId); - expect(list.length).toBeGreaterThanOrEqual(1); + expect(list).toHaveLength(1); expect(list.some((c) => c.name === "Acme")).toBe(true); }); @@ -63,7 +63,7 @@ describe("competitor queries", () => { }); const due = await getDueSources(new Date()); // lastRunAt is null → due immediately - expect(due.length).toBeGreaterThanOrEqual(1); + expect(due).toHaveLength(1); }); it("insertSnapshot + getLatestSnapshot returns the most recent by source", async () => { @@ -106,7 +106,7 @@ describe("competitor queries", () => { }, ]); const changes = await listChanges(companyId); - expect(changes.length).toBeGreaterThanOrEqual(1); + expect(changes).toHaveLength(1); expect(changes.some((ch) => ch.changeType === "price_increase")).toBe(true); }); diff --git a/packages/db/src/queries/competitor.ts b/packages/db/src/queries/competitor.ts index 1c7f6ce9..7e156b9c 100644 --- a/packages/db/src/queries/competitor.ts +++ b/packages/db/src/queries/competitor.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { db } from "../index"; import { competitors, From c2a3e06df3af40779f3d1cf69712893bd8b65be7 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:02:04 +0530 Subject: [PATCH 06/55] feat(competitor): collector contract + pricing collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Collector interface (types.ts + httpFetch), pricingCollector (JSON-LD → DOM heuristic fallback), collector registry (index.ts), HTML fixtures, and tests. Social collector registration is left as a commented breadcrumb for Task 6. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/no-hardcoded-currency.test.ts | 6 ++ .../__tests__/fixtures/pricing-broken.html | 1 + .../__tests__/fixtures/pricing-dom.html | 4 ++ .../__tests__/fixtures/pricing-jsonld.html | 5 ++ .../collectors/__tests__/pricing.test.ts | 30 ++++++++ .../src/lib/competitor/collectors/index.ts | 14 ++++ .../src/lib/competitor/collectors/pricing.ts | 71 +++++++++++++++++++ .../src/lib/competitor/collectors/types.ts | 46 ++++++++++++ 8 files changed, 177 insertions(+) create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-broken.html create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom.html create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld.html create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts create mode 100644 apps/web/src/lib/competitor/collectors/index.ts create mode 100644 apps/web/src/lib/competitor/collectors/pricing.ts create mode 100644 apps/web/src/lib/competitor/collectors/types.ts diff --git a/apps/web/src/__tests__/no-hardcoded-currency.test.ts b/apps/web/src/__tests__/no-hardcoded-currency.test.ts index ef9e4cd6..86438f87 100644 --- a/apps/web/src/__tests__/no-hardcoded-currency.test.ts +++ b/apps/web/src/__tests__/no-hardcoded-currency.test.ts @@ -49,6 +49,12 @@ const ALLOWED = [ // static bound labels on range inputs (min/max markers), not user data. "apps/web/src/app/(dashboard)/funding/funding-details.tsx", + // ── Competitor pricing collector ───────────────────────────────────────── + // pricing.ts: /(\$|€|EUR|£|GBP)?/ and /(\$|€|£)/ regex patterns used to + // PARSE currency symbols from third-party competitor HTML pages. + // symbolToCurrency map normalises parsed glyphs to ISO codes. Not display code. + "apps/web/src/lib/competitor/collectors/pricing.ts", + // ── CSV import parser ───────────────────────────────────────────────────── // import-flow.tsx line 134: /[$,€£()]/ in a regex to STRIP currency // characters from user-supplied CSV amounts. Not display code. diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-broken.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-broken.html new file mode 100644 index 00000000..a40e0209 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-broken.html @@ -0,0 +1 @@ +Coming soon diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom.html new file mode 100644 index 00000000..f64e1096 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom.html @@ -0,0 +1,4 @@ + +

Starter

$9/mo
+

Pro

$29/mo
+ diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld.html new file mode 100644 index 00000000..22c999f1 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld.html @@ -0,0 +1,5 @@ + + +

Pricing

diff --git a/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts new file mode 100644 index 00000000..b5a7196b --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pricingCollector } from "../pricing"; + +const fx = (n: string) => readFileSync(join(__dirname, "fixtures", n), "utf8"); +const cap = (raw: string) => ({ contentType: "text/html", raw, fetchedAt: new Date(0), status: 200 }); +const src = { id: "s1", type: "pricing", url: "https://x/pricing" }; + +describe("pricingCollector.parse", () => { + it("extracts plans from JSON-LD with high confidence", () => { + const r = pricingCollector.parse(cap(fx("pricing-jsonld.html")), src); + const plans = (r.structured as any).plans; + expect(plans[0].name).toBe("Pro"); + expect(plans[0].price.amount).toBe(29); + expect(plans[0].price.currency).toBe("USD"); + expect(r.confidence).toBeGreaterThan(0.7); + }); + + it("falls back to DOM heuristics with moderate confidence", () => { + const r = pricingCollector.parse(cap(fx("pricing-dom.html")), src); + expect((r.structured as any).plans.length).toBeGreaterThan(0); + expect(r.confidence).toBeGreaterThan(0.3); + }); + + it("returns low confidence when nothing parses", () => { + const r = pricingCollector.parse(cap(fx("pricing-broken.html")), src); + expect(r.confidence).toBeLessThan(0.4); + }); +}); diff --git a/apps/web/src/lib/competitor/collectors/index.ts b/apps/web/src/lib/competitor/collectors/index.ts new file mode 100644 index 00000000..cdbe91a0 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/index.ts @@ -0,0 +1,14 @@ +import type { Collector } from "./types"; +import { pricingCollector } from "./pricing"; +// import { socialCollector } from "./social"; // Task 6 adds this + +const COLLECTORS: Record = { + [pricingCollector.type]: pricingCollector, + // [socialCollector.type]: socialCollector, // Task 6 +}; + +export function getCollector(type: string): Collector | null { + return COLLECTORS[type] ?? null; +} + +export * from "./types"; diff --git a/apps/web/src/lib/competitor/collectors/pricing.ts b/apps/web/src/lib/competitor/collectors/pricing.ts new file mode 100644 index 00000000..9ff16375 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/pricing.ts @@ -0,0 +1,71 @@ +import type { Collector, ParseResult, RawCapture } from "./types"; +import { httpFetch } from "./types"; + +interface Plan { + name: string; + price: { amount: number | null; currency: string | null; period: string | null }; + features: string[]; +} + +function parsePrice(s: string): { amount: number | null; currency: string | null; period: string | null } { + const m = s.replace(/,/g, "").match(/(\$|USD|€|EUR|£|GBP)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:\/\s*(mo|month|yr|year))?/i); + const symbolToCurrency: Record = { "$": "USD", "€": "EUR", "£": "GBP" }; + if (!m) return { amount: null, currency: null, period: null }; + return { + amount: Number(m[2]), + currency: m[1] ? (symbolToCurrency[m[1]] ?? m[1].toUpperCase()) : null, + period: m[3] ? (m[3].startsWith("y") ? "year" : "month") : null, + }; +} + +function fromJsonLd(html: string): Plan[] { + const plans: Plan[] = []; + const re = /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(html))) { + try { + const data = JSON.parse((m[1] ?? "").trim()); + for (const node of Array.isArray(data) ? data : [data]) { + if (node && node["@type"] === "Product" && node.offers) { + const offer = Array.isArray(node.offers) ? node.offers[0] : node.offers; + plans.push({ + name: String(node.name ?? "Plan"), + price: { + amount: offer.price != null ? Number(offer.price) : null, + currency: offer.priceCurrency ?? null, + period: null, + }, + features: [], + }); + } + } + } catch { + /* ignore malformed block */ + } + } + return plans; +} + +function fromDomHeuristic(html: string): Plan[] { + const plans: Plan[] = []; + const blockRe = /<(?:div|section|li)[^>]*class=["'][^"']*plan[^"']*["'][\s\S]*?<\/(?:div|section|li)>/gi; + const blocks = html.match(blockRe) ?? []; + for (const b of blocks) { + const name = (b.match(/]*>([^<]+)<\/h[1-6]>/i)?.[1] ?? "Plan").trim(); + const priceText = b.match(/(\$|€|£)\s*[0-9][0-9.,]*\s*(?:\/\s*(?:mo|month|yr|year))?/i)?.[0]; + if (priceText) plans.push({ name, price: parsePrice(priceText), features: [] }); + } + return plans; +} + +export const pricingCollector: Collector = { + type: "pricing", + fetch: (source) => httpFetch(source.url), + parse(raw: RawCapture): ParseResult { + const jsonld = fromJsonLd(raw.raw); + if (jsonld.length > 0) return { structured: { plans: jsonld }, confidence: 0.9 }; + const dom = fromDomHeuristic(raw.raw); + if (dom.length > 0) return { structured: { plans: dom }, confidence: 0.5 }; + return { structured: { plans: [] }, confidence: 0.1 }; + }, +}; diff --git a/apps/web/src/lib/competitor/collectors/types.ts b/apps/web/src/lib/competitor/collectors/types.ts new file mode 100644 index 00000000..113638ef --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/types.ts @@ -0,0 +1,46 @@ +import type { StructuredPayload } from "@burnless/engine"; + +export interface RawCapture { + contentType: string; + raw: string; + fetchedAt: Date; + status: number; +} + +export interface ParseResult { + structured: StructuredPayload; + confidence: number; +} + +export interface CollectorSource { + id: string; + type: string; + url: string; + config?: unknown; +} + +export interface Collector { + type: string; + fetch(source: CollectorSource): Promise; + parse(raw: RawCapture, source: CollectorSource): ParseResult; +} + +export async function httpFetch(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { "user-agent": "BurnlessCompetitorBot/1.0 (+https://burnless.ai)" }, + }); + const raw = await res.text(); + return { + contentType: res.headers.get("content-type") ?? "text/html", + raw, + fetchedAt: new Date(), + status: res.status, + }; + } finally { + clearTimeout(timer); + } +} From d63dea5c99e07677d5fd8bebdb67ca1fdb91aa4c Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:08:48 +0530 Subject: [PATCH 07/55] feat(competitor): social collector --- .../__tests__/fixtures/social-profile.html | 2 ++ .../collectors/__tests__/social.test.ts | 21 +++++++++++++++++ .../src/lib/competitor/collectors/index.ts | 4 ++-- .../src/lib/competitor/collectors/social.ts | 23 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/social-profile.html create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/social.test.ts create mode 100644 apps/web/src/lib/competitor/collectors/social.ts diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/social-profile.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/social-profile.html new file mode 100644 index 00000000..ba9ba719 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/social-profile.html @@ -0,0 +1,2 @@ + +12,300 Followers diff --git a/apps/web/src/lib/competitor/collectors/__tests__/social.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/social.test.ts new file mode 100644 index 00000000..88b1ac8f --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/social.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { socialCollector } from "../social"; + +const fx = (n: string) => readFileSync(join(__dirname, "fixtures", n), "utf8"); +const cap = (raw: string) => ({ contentType: "text/html", raw, fetchedAt: new Date(0), status: 200 }); + +describe("socialCollector.parse", () => { + it("extracts follower count", () => { + const r = socialCollector.parse(cap(fx("social-profile.html")), { id: "s", type: "social", url: "https://x.com/acme", config: { platform: "x", handle: "acme" } }); + expect((r.structured as any).followers).toBe(12300); + expect((r.structured as any).platform).toBe("x"); + expect(r.confidence).toBeGreaterThan(0.3); + }); + + it("returns low confidence when no follower count found", () => { + const r = socialCollector.parse(cap("nope"), { id: "s", type: "social", url: "u" }); + expect(r.confidence).toBeLessThan(0.4); + }); +}); diff --git a/apps/web/src/lib/competitor/collectors/index.ts b/apps/web/src/lib/competitor/collectors/index.ts index cdbe91a0..01327e25 100644 --- a/apps/web/src/lib/competitor/collectors/index.ts +++ b/apps/web/src/lib/competitor/collectors/index.ts @@ -1,10 +1,10 @@ import type { Collector } from "./types"; import { pricingCollector } from "./pricing"; -// import { socialCollector } from "./social"; // Task 6 adds this +import { socialCollector } from "./social"; const COLLECTORS: Record = { [pricingCollector.type]: pricingCollector, - // [socialCollector.type]: socialCollector, // Task 6 + [socialCollector.type]: socialCollector, }; export function getCollector(type: string): Collector | null { diff --git a/apps/web/src/lib/competitor/collectors/social.ts b/apps/web/src/lib/competitor/collectors/social.ts new file mode 100644 index 00000000..fd5c5a40 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/social.ts @@ -0,0 +1,23 @@ +import type { Collector, ParseResult, RawCapture, CollectorSource } from "./types"; +import { httpFetch } from "./types"; + +function parseFollowers(html: string): number | null { + const m = html.replace(/,/g, "").match(/([0-9]+(?:\.[0-9]+)?)([KMkm])?\s*followers/i); + if (!m) return null; + const base = Number(m[1]); + const mult = m[2]?.toLowerCase() === "k" ? 1_000 : m[2]?.toLowerCase() === "m" ? 1_000_000 : 1; + return Math.round(base * mult); +} + +export const socialCollector: Collector = { + type: "social", + fetch: (source) => httpFetch(source.url), + parse(raw: RawCapture, source: CollectorSource): ParseResult { + const cfg = (source.config ?? {}) as { platform?: string; handle?: string }; + const followers = parseFollowers(raw.raw); + return { + structured: { platform: cfg.platform ?? null, handle: cfg.handle ?? null, followers, posts: null }, + confidence: followers != null ? 0.6 : 0.1, + }; + }, +}; From 8c5838c18d0b6336cd69caffed804a366e21594f Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:21:27 +0530 Subject: [PATCH 08/55] =?UTF-8?q?feat(competitor):=20sync=20pipeline=20?= =?UTF-8?q?=E2=80=94=20snapshot/diff/alert/notify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `getCompanyNotifyUserIds(companyId)` in packages/db: returns owner + admin userIds for a company; exported from queries/index.ts - `apps/web/src/lib/competitor/notify.ts`: `buildDigest` reduces alerts into a single notification payload (highest severity wins) - `apps/web/src/lib/competitor/pipeline.ts`: `runSource` (fetch → parse → hash → store-on-change → diff → evaluateRules → insertChanges → notify) and `runDueCompetitorSyncs` for use by scheduler + sync route - Integration test with stub collector + real PGlite (|db| project): proves first-run snapshot creation, store-on-change dedup, and price_increase alert Co-Authored-By: Claude Sonnet 4.6 --- .../lib/competitor/__tests__/pipeline.test.ts | 140 ++++++++++++++++ apps/web/src/lib/competitor/notify.ts | 20 +++ apps/web/src/lib/competitor/pipeline.ts | 156 ++++++++++++++++++ packages/db/src/queries/company.ts | 20 ++- packages/db/src/queries/index.ts | 1 + 5 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/competitor/__tests__/pipeline.test.ts create mode 100644 apps/web/src/lib/competitor/notify.ts create mode 100644 apps/web/src/lib/competitor/pipeline.ts diff --git a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts new file mode 100644 index 00000000..b97b663a --- /dev/null +++ b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts @@ -0,0 +1,140 @@ +/** + * Integration test for the competitor sync pipeline. + * + * DB WIRING: imports @db-test factories → vitest.config.mts `needsDb()` detects + * this file as a "db" test → vitest.setup.db.ts runs first and assigns + * globalThis.__burnless_db to a fresh PGlite instance BEFORE @burnless/db + * evaluates. When pipeline.ts (and its @burnless/db imports) are loaded, they + * pick up the in-memory PGlite. The @burnless/db barrel is NOT mocked. + * + * COLLECTOR MOCK: vi.mock("../collectors") stubs getCollector so no network + * calls happen. The stub's parse() reads globalThis.__next at call-time so + * tests can change the structured payload between runs. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createUser, createCompany, createMember } from "@db-test/factories"; +import { + createCompetitor, + createSource, + getLatestSnapshot, + listChanges, +} from "@burnless/db"; + +// Stub the collector registry so no network calls happen. +// parse() reads globalThis.__next at call-time — set it before each runSource call. +vi.mock("../collectors", async (orig) => { + const actual = await (orig as () => Promise>)(); + return { + ...actual, + getCollector: () => ({ + type: "pricing", + fetch: async () => ({ + contentType: "text/html", + raw: "", + fetchedAt: new Date(), + status: 200, + }), + parse: () => ({ + structured: (globalThis as Record).__next, + confidence: 0.9, + }), + }), + }; +}); + +// Import AFTER the mock declaration so vitest hoisting applies. +import { runSource } from "../pipeline"; + +const PRICE_29 = { + plans: [ + { + name: "Pro", + price: { amount: 29, currency: "USD", period: "month" }, + features: [], + }, + ], +}; + +const PRICE_39 = { + plans: [ + { + name: "Pro", + price: { amount: 39, currency: "USD", period: "month" }, + features: [], + }, + ], +}; + +describe("competitor sync pipeline", () => { + let companyId: string; + let sourceRow: Awaited>; + + beforeEach(async () => { + const user = await createUser(); + const company = await createCompany(user.id); + // createMember defaults to role:"owner" → getCompanyNotifyUserIds returns this userId + await createMember(company.id, user.id); + companyId = company.id; + + const competitor = await createCompetitor({ + companyId, + name: "Acme", + url: "https://acme.com", + }); + sourceRow = await createSource({ + companyId, + competitorId: competitor.id, + type: "pricing", + url: "https://acme.com/pricing", + }); + }); + + it("first run stores a snapshot and returns changed:true", async () => { + (globalThis as Record).__next = PRICE_29; + + const result = await runSource(sourceRow); + + expect(result.changed).toBe(true); + expect(result.broken).toBe(false); + + const snap = await getLatestSnapshot(sourceRow.id); + expect(snap).toBeTruthy(); + expect(snap!.structuredHash).toBeTruthy(); + }); + + it("unchanged re-run creates NO new snapshot (store-on-change guard)", async () => { + (globalThis as Record).__next = PRICE_29; + + // First run — stores snapshot + await runSource(sourceRow); + const snapAfterFirst = await getLatestSnapshot(sourceRow.id); + + // Second run — same data, must not create another snapshot + const result = await runSource(sourceRow); + expect(result.changed).toBe(false); + expect(result.broken).toBe(false); + + const snapAfterSecond = await getLatestSnapshot(sourceRow.id); + // Still the same snapshot row + expect(snapAfterSecond!.id).toBe(snapAfterFirst!.id); + }); + + it("changed price stores a new snapshot and emits a price_increase change row", async () => { + // Run 1: establish baseline at price 29 + (globalThis as Record).__next = PRICE_29; + await runSource(sourceRow); + + // Run 2: price bumped to 39 — should diff and emit price_increase + (globalThis as Record).__next = PRICE_39; + const result = await runSource(sourceRow); + + expect(result.changed).toBe(true); + expect(result.broken).toBe(false); + expect(result.alerts.length).toBeGreaterThan(0); + expect(result.alerts.some((a) => a.changeType === "price_increase")).toBe(true); + + const changes = await listChanges(companyId); + expect(changes.length).toBeGreaterThan(0); + expect(changes.some((c) => c.changeType === "price_increase")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/competitor/notify.ts b/apps/web/src/lib/competitor/notify.ts new file mode 100644 index 00000000..b26a3a4b --- /dev/null +++ b/apps/web/src/lib/competitor/notify.ts @@ -0,0 +1,20 @@ +import type { Alert, Severity } from "@burnless/engine"; + +const RANK: Record = { info: 0, success: 1, warning: 2, error: 3 }; + +/** + * Reduce a list of competitor alerts into a single digest notification payload. + * The digest severity is the highest severity across all alerts. + */ +export function buildDigest( + competitorName: string, + alerts: Alert[], +): { title: string; body: string; severity: Severity } { + const severity = alerts.reduce( + (acc, a) => (RANK[a.severity] > RANK[acc] ? a.severity : acc), + "info", + ); + const title = `${alerts.length} change${alerts.length === 1 ? "" : "s"} at ${competitorName}`; + const body = alerts.map((a) => `• ${a.summary}`).join("\n"); + return { title, body, severity }; +} diff --git a/apps/web/src/lib/competitor/pipeline.ts b/apps/web/src/lib/competitor/pipeline.ts new file mode 100644 index 00000000..9bbfd2c5 --- /dev/null +++ b/apps/web/src/lib/competitor/pipeline.ts @@ -0,0 +1,156 @@ +import { sha256hex } from "@burnless/db"; +import { diffStructured, evaluateRules, type Alert } from "@burnless/engine"; +import type { CompetitorSource } from "@burnless/db"; +import { + getDueSources, + getLatestSnapshot, + insertSnapshot, + insertChanges, + updateSource, + getCompetitor, + createNotification, + getCompanyNotifyUserIds, +} from "@burnless/db"; +import { getCollector, type CollectorSource } from "./collectors"; +import { buildDigest } from "./notify"; + +/** + * Run a single competitor source through the full pipeline: + * fetch → parse → hash → store-on-change → diff → rules → insert changes → notify. + * + * Returns: + * - changed: true if a new snapshot was stored (structured data changed) + * - broken: true if fetch/parse/collector failed + * - alerts: the rule alerts emitted (empty when no previous snapshot to diff against) + */ +export async function runSource( + source: CompetitorSource, +): Promise<{ changed: boolean; broken: boolean; alerts: Alert[] }> { + const collector = getCollector(source.type); + if (!collector) { + await updateSource(source.id, source.companyId, { + lastRunAt: new Date(), + lastStatus: "no_collector", + healthState: "broken", + }); + return { changed: false, broken: true, alerts: [] }; + } + + let raw: Awaited>; + try { + raw = await collector.fetch(source as unknown as CollectorSource); + } catch (e) { + await updateSource(source.id, source.companyId, { + lastRunAt: new Date(), + lastStatus: `fetch_error: ${(e as Error).message}`, + healthState: "broken", + }); + return { changed: false, broken: true, alerts: [] }; + } + + const parsed = collector.parse(raw, source as unknown as CollectorSource); + if (parsed.confidence < 0.4) { + await updateSource(source.id, source.companyId, { + lastRunAt: new Date(), + lastStatus: "low_confidence", + healthState: "broken", + }); + return { changed: false, broken: true, alerts: [] }; + } + + const structuredHash = sha256hex(JSON.stringify(parsed.structured)); + const latest = await getLatestSnapshot(source.id); + + // Store-on-change: skip if nothing changed. + if (latest && latest.structuredHash === structuredHash) { + await updateSource(source.id, source.companyId, { + lastRunAt: new Date(), + lastStatus: "ok", + healthState: "ok", + }); + return { changed: false, broken: false, alerts: [] }; + } + + const snapshot = await insertSnapshot({ + companyId: source.companyId, + competitorId: source.competitorId, + sourceId: source.id, + raw: raw.raw, + rawHash: sha256hex(raw.raw), + structured: parsed.structured, + structuredHash, + }); + + let alerts: Alert[] = []; + + // Only diff when a previous snapshot exists. + if (latest) { + const changes = diffStructured(latest.structured, parsed.structured); + alerts = evaluateRules(source.type, changes); + + if (alerts.length > 0) { + await insertChanges( + alerts.map((a) => ({ + companyId: source.companyId, + competitorId: source.competitorId, + sourceId: source.id, + snapshotId: snapshot.id, + changeType: a.changeType, + summary: a.summary, + before: a.before ?? null, + after: a.after ?? null, + severity: a.severity, + })), + ); + + const competitor = await getCompetitor(source.competitorId, source.companyId); + const digest = buildDigest(competitor?.name ?? "competitor", alerts); + const userIds = await getCompanyNotifyUserIds(source.companyId); + for (const userId of userIds) { + await createNotification({ + companyId: source.companyId, + userId, + category: "competitor", + title: digest.title, + body: digest.body, + severity: digest.severity, + link: `/competitors/${source.competitorId}`, + }); + } + } + } + + await updateSource(source.id, source.companyId, { + lastRunAt: new Date(), + lastStatus: "ok", + healthState: "ok", + }); + return { changed: true, broken: false, alerts }; +} + +/** + * Run all competitor sources that are currently due. + * Called by the scheduler (Task 8) and the /api/competitor/sync route (Task 12). + */ +export async function runDueCompetitorSyncs( + now: Date, +): Promise<{ ok: boolean; summary: string }> { + const due = await getDueSources(now); + let changed = 0; + let broken = 0; + + for (const s of due) { + try { + const r = await runSource(s); + if (r.changed) changed++; + if (r.broken) broken++; + } catch { + broken++; + } + } + + return { + ok: true, + summary: `${due.length} due, ${changed} changed, ${broken} need attention`, + }; +} diff --git a/packages/db/src/queries/company.ts b/packages/db/src/queries/company.ts index ca6d1ade..03463e1b 100644 --- a/packages/db/src/queries/company.ts +++ b/packages/db/src/queries/company.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { db } from "../index"; import { companies, companyMembers, users } from "../schema"; @@ -48,6 +48,24 @@ export async function getCompanyById(companyId: string) { return row ?? null; } +/** + * Return the userIds of all owner and admin members for a company. + * Used to fan-out notifications (e.g. competitor change alerts) to the right + * recipients without exposing editor/viewer members to ops events. + */ +export async function getCompanyNotifyUserIds(companyId: string): Promise { + const rows = await db + .select({ userId: companyMembers.userId }) + .from(companyMembers) + .where( + and( + eq(companyMembers.companyId, companyId), + inArray(companyMembers.role, ["owner", "admin"]), + ), + ); + return rows.map((r) => r.userId); +} + /** All memberships of a user with company display fields — used by the OAuth * consent company picker (expose spec §5.2: multi-company users pick the * tenant a grant is bound to). */ diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index e24d46de..e1734b72 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -3,6 +3,7 @@ export { getUserWithCompany, getCompanyById, listCompaniesForUser, + getCompanyNotifyUserIds, } from "./company"; export { From 30a67408667904e8b1cd2285d2333a60b68fb43b Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:27:42 +0530 Subject: [PATCH 09/55] feat(competitor): hourly system job runs due competitor syncs Co-Authored-By: Claude Sonnet 4.6 --- .../lib/scheduler/__tests__/system-jobs.test.ts | 14 ++++++++++++++ apps/web/src/lib/scheduler/system-jobs.ts | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/web/src/lib/scheduler/__tests__/system-jobs.test.ts b/apps/web/src/lib/scheduler/__tests__/system-jobs.test.ts index db9f077f..822fa32c 100644 --- a/apps/web/src/lib/scheduler/__tests__/system-jobs.test.ts +++ b/apps/web/src/lib/scheduler/__tests__/system-jobs.test.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/cron/batch-regenerate", () => ({ vi.mock("@/lib/integrations/run-all-syncs", () => ({ runAllIntegrationSyncs: vi.fn().mockResolvedValue({ synced: 0, failed: 0 }), })); +vi.mock("@/lib/competitor/pipeline", () => ({ + runDueCompetitorSyncs: vi.fn().mockResolvedValue({ ok: true, summary: "0 synced, 0 skipped, 0 failed" }), +})); describe("SYSTEM_JOBS registry", () => { it("has unique ids", () => { @@ -73,3 +76,14 @@ describe("integration-sync system job", () => { expect(result.summary).toContain("Synced"); }); }); + +describe("competitor-sync system job", () => { + it("is registered with the hourly schedule and its run() resolves ok", async () => { + const job = SYSTEM_JOBS.find((j) => j.id === "competitor-sync"); + expect(job).toBeDefined(); + expect(job!.schedule).toBe("0 * * * *"); + const result = await job!.run(); + expect(result.ok).toBe(true); + expect(result.summary).toContain("synced"); + }); +}); diff --git a/apps/web/src/lib/scheduler/system-jobs.ts b/apps/web/src/lib/scheduler/system-jobs.ts index 6cfdbed7..fb678c76 100644 --- a/apps/web/src/lib/scheduler/system-jobs.ts +++ b/apps/web/src/lib/scheduler/system-jobs.ts @@ -4,6 +4,7 @@ import { cleanupExpiredData } from "@/lib/data-retention"; import { runWeeklyDigest } from "@/lib/cron/weekly-digest"; import { runBatchRegenerate } from "@/lib/cron/batch-regenerate"; import { runAllIntegrationSyncs } from "@/lib/integrations/run-all-syncs"; +import { runDueCompetitorSyncs } from "@/lib/competitor/pipeline"; /** * Operational jobs registered in code (NOT in the scheduledJobs table). The @@ -46,4 +47,12 @@ export const SYSTEM_JOBS: SystemJob[] = [ return { ok: true, summary: `Synced ${r.synced} integration(s), ${r.failed} failed` }; }, }, + { + id: "competitor-sync", + schedule: "0 * * * *", // hourly tick; each source runs only when its intervalHours is due + run: async () => { + const r = await runDueCompetitorSyncs(new Date()); + return { ok: r.ok, summary: r.summary }; + }, + }, ]; From 56a2e967f40e7fd712ad50ab8c3c9ad3b31ae50f Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:35:24 +0530 Subject: [PATCH 10/55] feat(competitor): read-only AI tools (list_competitors, list_competitor_changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two deterministic, read-only tools wired through the existing tool registry (toolSchemas + toolHandlers in index.ts, mirroring calculate): - list_competitors — queries listCompetitors(companyId), returns text - list_competitor_changes — queries listChanges(companyId, {limit}), returns text Both have no `mutates`, require no AI provider to execute, and are MCP-exposed by default. Zod schemas added to toolSchemas so validateToolInput passes. All 27 ai-tools test files green; all @burnless/ai guard tests (tools-naming, registry-derivation) still pass. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ai-tools/__tests__/competitor.test.ts | 153 ++++++++++++++++++ apps/web/src/lib/ai-tools/competitor.ts | 73 +++++++++ apps/web/src/lib/ai-tools/index.ts | 3 + 3 files changed, 229 insertions(+) create mode 100644 apps/web/src/lib/ai-tools/__tests__/competitor.test.ts create mode 100644 apps/web/src/lib/ai-tools/competitor.ts diff --git a/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts b/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts new file mode 100644 index 00000000..886e5bce --- /dev/null +++ b/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the competitor read-only AI toolset (Task 9): + * list_competitors + list_competitor_changes. + * + * HARNESS: real PGLite via @db-test — mirrors transactions.test.ts wiring. + * The DB is real; only framework seams pulled by the import graph are mocked. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createUser, createCompany } from "@db-test/factories"; +import { createCompetitor, insertChanges } from "@burnless/db"; +import type { ToolContext } from "../types"; + +// ── Framework seam mocks ────────────────────────────────────────────────────── +vi.mock("next/cache", () => ({ + unstable_cache: (fn: (...args: unknown[]) => unknown) => fn, + revalidateTag: vi.fn(), +})); +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cache: (fn: unknown) => fn }; +}); +vi.mock("@/lib/auth", () => ({ + auth: vi.fn().mockResolvedValue(null), +})); +vi.mock("next/headers", () => ({ + cookies: vi.fn().mockResolvedValue({ get: () => undefined }), +})); + +import { competitorHandlers, competitorTools } from "../competitor"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +let ctx: ToolContext; +let companyId: string; + +beforeEach(async () => { + const user = await createUser(); + const company = await createCompany(user.id); + companyId = company.id; + ctx = { companyId, userId: user.id }; +}); + +// ── Tool definition guard ───────────────────────────────────────────────────── + +describe("competitorTools definitions", () => { + it("declares two read-only tools (no mutates)", () => { + const names = competitorTools.map((t) => t.name).sort(); + expect(names).toEqual(["list_competitor_changes", "list_competitors"]); + expect(competitorTools.every((t) => !t.mutates)).toBe(true); + }); +}); + +// ── list_competitors ────────────────────────────────────────────────────────── + +describe("list_competitors", () => { + it("returns a friendly message when no competitors tracked", async () => { + const out = await competitorHandlers["list_competitors"]!({}, ctx); + expect(out).toMatch(/no competitors/i); + }); + + it("returns the company's competitors as text", async () => { + await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const out = await competitorHandlers["list_competitors"]!({}, ctx); + expect(out).toContain("Acme"); + expect(out).toContain("https://acme.com"); + }); + + it("returns 'No company in context.' when companyId is missing", async () => { + const noCtx: ToolContext = { userId: "u1" }; + const out = await competitorHandlers["list_competitors"]!({}, noCtx); + expect(out).toBe("No company in context."); + }); + + it("does not leak competitors from another company", async () => { + const other = await createUser(); + const otherCompany = await createCompany(other.id); + await createCompetitor({ companyId: otherCompany.id, name: "OtherCo", url: "https://other.com" }); + const out = await competitorHandlers["list_competitors"]!({}, ctx); + expect(out).not.toContain("OtherCo"); + }); +}); + +// ── list_competitor_changes ─────────────────────────────────────────────────── + +describe("list_competitor_changes", () => { + it("returns a friendly message when no changes detected", async () => { + const out = await competitorHandlers["list_competitor_changes"]!({}, ctx); + expect(out).toMatch(/no competitor changes/i); + }); + + it("returns changes for the company", async () => { + // Create a competitor + source + snapshot + change via insertChanges + const { getTestDb } = await import("@db-test/setup"); + const db = getTestDb(); + const { competitorSources, competitorSnapshots } = await import("@burnless/db"); + + const comp = await createCompetitor({ companyId, name: "Rival", url: "https://rival.com" }); + + // Insert a source directly + const [src] = await db + .insert(competitorSources) + .values({ + competitorId: comp.id, + companyId, + type: "pricing", + url: "https://rival.com/pricing", + }) + .returning(); + + // Insert a snapshot + const [snap] = await db + .insert(competitorSnapshots) + .values({ + competitorId: comp.id, + sourceId: src!.id, + companyId, + raw: "{}", + rawHash: "abc", + structured: {}, + structuredHash: "def", + }) + .returning(); + + await insertChanges([ + { + competitorId: comp.id, + sourceId: src!.id, + snapshotId: snap!.id, + companyId, + changeType: "price_change", + summary: "Starter plan dropped from $49 to $39", + severity: "high", + }, + ]); + + const out = await competitorHandlers["list_competitor_changes"]!({}, ctx); + expect(out).toContain("price_change"); + expect(out).toContain("Starter plan dropped"); + expect(out).toContain("high"); + }); + + it("respects the limit parameter", async () => { + const out = await competitorHandlers["list_competitor_changes"]!({ limit: 5 }, ctx); + // No changes → friendly message (also proves limit doesn't crash) + expect(out).toMatch(/no competitor changes/i); + }); + + it("returns 'No company in context.' when companyId is missing", async () => { + const noCtx: ToolContext = { userId: "u1" }; + const out = await competitorHandlers["list_competitor_changes"]!({}, noCtx); + expect(out).toBe("No company in context."); + }); +}); diff --git a/apps/web/src/lib/ai-tools/competitor.ts b/apps/web/src/lib/ai-tools/competitor.ts new file mode 100644 index 00000000..7e2f95a3 --- /dev/null +++ b/apps/web/src/lib/ai-tools/competitor.ts @@ -0,0 +1,73 @@ +/** + * Competitor read-only AI tools (Task 9 — competitor analysis spine). + * + * `list_competitors` — returns the company's tracked competitors. + * `list_competitor_changes` — returns recent detected changes across competitors. + * + * READ-ONLY: no `mutates`, no AI provider needed to run (deterministic DB query). + * These give the AI grounding but require no special capability. + */ + +import { z } from "zod"; +import type { ToolDefinition } from "@burnless/ai"; +import { listCompetitors, listChanges } from "@burnless/db"; +import type { ToolHandler } from "./types"; + +// ── Tool definitions ────────────────────────────────────────────────────────── + +export const competitorTools: ToolDefinition[] = [ + { + name: "list_competitors", + description: + "List the competitors tracked for this company (name, URL, status). Use this to see which competitors are being monitored and to resolve competitor names before querying changes.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "list_competitor_changes", + description: + "List recent detected changes across tracked competitors (e.g. price changes, new pricing plans, follower count shifts). Returns the most recent changes first.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Maximum number of changes to return (default 20).", + }, + }, + }, + }, +]; + +// ── Zod schemas (for toolSchemas / validateToolInput in index.ts) ───────────── + +const listCompetitorsSchema = z.object({}); + +const listCompetitorChangesSchema = z.object({ + limit: z.number().int().positive().optional(), +}); + +export const competitorSchemas: Record = { + list_competitors: listCompetitorsSchema, + list_competitor_changes: listCompetitorChangesSchema, +}; + +// ── Handlers ────────────────────────────────────────────────────────────────── + +export const competitorHandlers: Record = { + async list_competitors(_input, ctx) { + if (!ctx.companyId) return "No company in context."; + const rows = await listCompetitors(ctx.companyId); + if (rows.length === 0) return "No competitors are being tracked yet."; + return rows.map((c) => `- ${c.name} (${c.url}) [${c.status}]`).join("\n"); + }, + + async list_competitor_changes(input, ctx) { + if (!ctx.companyId) return "No company in context."; + const limit = typeof input.limit === "number" ? input.limit : 20; + const rows = await listChanges(ctx.companyId, { limit }); + if (rows.length === 0) return "No competitor changes detected yet."; + return rows + .map((r) => `- [${r.severity}] ${r.changeType}: ${r.summary}`) + .join("\n"); + }, +}; diff --git a/apps/web/src/lib/ai-tools/index.ts b/apps/web/src/lib/ai-tools/index.ts index fed0642d..38b2274c 100644 --- a/apps/web/src/lib/ai-tools/index.ts +++ b/apps/web/src/lib/ai-tools/index.ts @@ -40,6 +40,7 @@ import { transactionSchemas, transactionHandlers } from "./transactions"; import { companyKnowledgeSchemas, companyKnowledgeHandlers } from "./company-knowledge"; import { skillsSchemas, skillsHandlers } from "./skills"; import { calculateSchemas, calculateHandlers } from "./calculate"; +import { competitorSchemas, competitorHandlers } from "./competitor"; // NOTE: "./mcp-describe" only — "./mcp" pulls next-auth via ai-feature-flags // and is loaded lazily inside executeToolCall instead. import { describeMcpToolAction } from "./mcp-describe"; @@ -79,6 +80,7 @@ const toolSchemas: Record = { ...companyKnowledgeSchemas, ...skillsSchemas, ...calculateSchemas, + ...competitorSchemas, }; const toolHandlers: Record = { @@ -95,6 +97,7 @@ const toolHandlers: Record = { ...companyKnowledgeHandlers, ...skillsHandlers, ...calculateHandlers, + ...competitorHandlers, }; // ── Mutation tagging (for guardrail enforcement) ──────────────────────────── From 96d6d778520a8455ab3f58433a816828a6a33cec Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:54:46 +0530 Subject: [PATCH 11/55] feat(competitor): register DomainModule (contributor + nav + gating) Create domains/competitor.ts with competitorDomainModule (non-core, tools: competitorTools gates LLM visibility), competitorContributor (recent-changes context, order 40, graceful-degradation), and competitorNavEntries (Swords icon). Register in registerDomains(); add one Competitors entry to coreNavItems in nav-config.ts. TDD: 4 shape assertions GREEN; all other domain tests unaffected. Co-Authored-By: Claude Sonnet 4.6 --- .../(dashboard)/dashboard-shell/nav-config.ts | 3 + .../__tests__/competitor-module.test.ts | 55 ++++++++++++++ apps/web/src/lib/domains/competitor.ts | 74 +++++++++++++++++++ apps/web/src/lib/domains/index.ts | 2 + 4 files changed, 134 insertions(+) create mode 100644 apps/web/src/lib/domains/__tests__/competitor-module.test.ts create mode 100644 apps/web/src/lib/domains/competitor.ts diff --git a/apps/web/src/app/(dashboard)/dashboard-shell/nav-config.ts b/apps/web/src/app/(dashboard)/dashboard-shell/nav-config.ts index e412ac12..3b8d7f7e 100644 --- a/apps/web/src/app/(dashboard)/dashboard-shell/nav-config.ts +++ b/apps/web/src/app/(dashboard)/dashboard-shell/nav-config.ts @@ -10,6 +10,7 @@ import { FolderOpen, Plug, Clock, + Swords, type LucideIcon, } from "lucide-react"; @@ -40,6 +41,8 @@ export const coreNavItems: NavItem[] = [ { id: "data-room", href: "/data-room", label: "Data Room", icon: FolderOpen }, { id: "connections", href: "/connections", label: "Connections", icon: Plug }, { id: "automations", href: "/automations", label: "Automations", icon: Clock }, + // competitor domain (Task 10) — one acknowledged base-touch per A3 breadcrumb + { id: "competitors", href: "/competitors", label: "Competitors", icon: Swords }, ]; export const aiNavItem: NavItem = { id: "ai", href: "/ai", label: "Companion", icon: Sparkles }; diff --git a/apps/web/src/lib/domains/__tests__/competitor-module.test.ts b/apps/web/src/lib/domains/__tests__/competitor-module.test.ts new file mode 100644 index 00000000..d7b57829 --- /dev/null +++ b/apps/web/src/lib/domains/__tests__/competitor-module.test.ts @@ -0,0 +1,55 @@ +/** + * competitor domain module — shape test (Task 10). + * + * Proves: id, non-core, exactly the two read tools, and a nav entry exist. + * Mocks mirror company-knowledge.test.ts for transitively-imported DB deps. + */ + +import { describe, it, expect, vi } from "vitest"; + +// ── DB stub: competitor.ts imports listChanges; ai-tools/competitor imports both ── +vi.mock("@burnless/db", () => ({ + listChanges: vi.fn(async () => []), + listCompetitors: vi.fn(async () => []), +})); + +// ── From the brief (Task 10) ────────────────────────────────────────────────── + +describe("competitorDomainModule — structural", () => { + it("is a non-core domain with id 'competitor' and the two read tools", async () => { + const { competitorDomainModule } = await import("../competitor"); + expect(competitorDomainModule.id).toBe("competitor"); + expect(competitorDomainModule.core).toBeFalsy(); + expect(competitorDomainModule.tools.map((t) => t.name).sort()).toEqual([ + "list_competitor_changes", + "list_competitors", + ]); + expect(competitorDomainModule.navEntries.length).toBeGreaterThan(0); + }); + + it("navEntries items conform to DomainNavEntry shape (id, href, label, icon as string)", async () => { + const { competitorDomainModule } = await import("../competitor"); + for (const entry of competitorDomainModule.navEntries) { + expect(typeof entry.id).toBe("string"); + expect(typeof entry.href).toBe("string"); + expect(typeof entry.label).toBe("string"); + expect(typeof entry.icon).toBe("string"); + } + }); + + it("has a context contributor with id 'competitor-recent-changes'", async () => { + const { competitorDomainModule } = await import("../competitor"); + expect( + competitorDomainModule.contextContributors.map((c) => c.id), + ).toContain("competitor-recent-changes"); + }); + + it("has no handlers in competitorDomainModule.handlers for non-existent tools", async () => { + const { competitorDomainModule } = await import("../competitor"); + // handlers keys must be exactly the two tool names + expect(Object.keys(competitorDomainModule.handlers).sort()).toEqual([ + "list_competitor_changes", + "list_competitors", + ]); + }); +}); diff --git a/apps/web/src/lib/domains/competitor.ts b/apps/web/src/lib/domains/competitor.ts new file mode 100644 index 00000000..7ad5a0db --- /dev/null +++ b/apps/web/src/lib/domains/competitor.ts @@ -0,0 +1,74 @@ +/** + * Competitor domain module (Task 10). + * + * Non-core domain: gated by the per-company aiFeatureFlags.features["competitor"] + * toggle (default on). No deployment capability key required — mirrors company-knowledge. + * + * core:false — no `capability` field set; isDomainEnabled falls through to the + * per-company flag with a default-on behaviour, consistent with company-knowledge. + * + * mcpExclude omitted → list_competitors / list_competitor_changes are MCP-exposed too + * (both are read-only and safe to surface via MCP). + * + * NOTE: the financial naming guard (tools-naming.test.ts) only iterates + * getFinancialTools(), so these list_* tools are not covered by it. If that guard + * is ever extended to domain tools, add list_competitors / list_competitor_changes + * to CONTROL_TOOLS (same bucket as list_accounts). + */ + +import type { + ContextContributor, + ContextSection, + ContributeCtx, +} from "@burnless/ai"; +import { listChanges } from "@burnless/db"; +import { competitorTools, competitorHandlers } from "@/lib/ai-tools/competitor"; +import type { DomainModule, DomainNavEntry } from "./contracts"; + +const DOMAIN = "competitor"; + +// ── Context contributor ──────────────────────────────────────────────────────── + +export const competitorContributor: ContextContributor = { + id: "competitor-recent-changes", + domain: DOMAIN, + async sections(ctx: ContributeCtx): Promise { + try { + const rows = await listChanges(ctx.companyId, { limit: 10 }); + if (rows.length === 0) return []; + const body = rows + .map((r) => `- ${r.summary}`) + .join("\n"); + return [{ heading: "Recent competitor changes", body, order: 40 }]; + } catch { + // Graceful degradation: a read failure must never break the turn. + return []; + } + }, +}; + +// ── Nav entries ──────────────────────────────────────────────────────────────── + +/** Backend nav entry: icon as Lucide component name string (sidebar maps to component). */ +export const competitorNavEntries: DomainNavEntry[] = [ + { + id: "competitors", + href: "/competitors", + label: "Competitors", + icon: "Swords", + }, +]; + +// ── Domain module ────────────────────────────────────────────────────────────── + +export const competitorDomainModule: DomainModule = { + id: DOMAIN, + // non-core: gated by per-company toggle (aiFeatureFlags.features["competitor"]), + // default on. No `capability` key — same pattern as company-knowledge. + tools: competitorTools, // ← surfaces list_competitors + list_competitor_changes to the LLM + handlers: competitorHandlers, + contextContributors: [competitorContributor], + promptSections: [], + navEntries: competitorNavEntries, + // mcpExclude omitted → both read tools are exposed over MCP. +}; diff --git a/apps/web/src/lib/domains/index.ts b/apps/web/src/lib/domains/index.ts index 012b2fff..b45311b5 100644 --- a/apps/web/src/lib/domains/index.ts +++ b/apps/web/src/lib/domains/index.ts @@ -15,6 +15,7 @@ import { companyKnowledgeModule } from "./company-knowledge"; import { memoryDomainModule } from "./memory"; import { skillsDomainModule } from "./skills"; import { integrationsDomainModule } from "./integrations"; +import { competitorDomainModule } from "./competitor"; let registered = false; @@ -26,6 +27,7 @@ export function registerDomains(): void { domainRegistry.register(memoryDomainModule); domainRegistry.register(skillsDomainModule); domainRegistry.register(integrationsDomainModule); + domainRegistry.register(competitorDomainModule); } // Auto-register at module load so any importer gets a populated registry. From 0eb4b8823e2d8fd67e70f394cf5da47d5a53d5e7 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 11:57:35 +0530 Subject: [PATCH 12/55] fix(competitor): remove hardcoded currency symbol from ai-tools test (currency guard) --- apps/web/src/lib/ai-tools/__tests__/competitor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts b/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts index 886e5bce..77e32a2b 100644 --- a/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts +++ b/apps/web/src/lib/ai-tools/__tests__/competitor.test.ts @@ -128,7 +128,7 @@ describe("list_competitor_changes", () => { snapshotId: snap!.id, companyId, changeType: "price_change", - summary: "Starter plan dropped from $49 to $39", + summary: "Starter plan dropped from 49 to 39", severity: "high", }, ]); From cd8bd8618cdf1b9dc38250b910146820251c4927 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:06:06 +0530 Subject: [PATCH 13/55] feat(competitor): competitors CRUD API GET/POST /api/competitors and GET/PATCH/DELETE /api/competitors/[id] mirroring the transactions/accounts sibling conventions exactly. requireDomainEnabled gates every handler; POST creates optional sources in the same transaction. Route test asserts 403 DOMAIN_DISABLED path. Guard allowlist extended: competitors cache is non-financial so trackDataMutation must not fire. Co-Authored-By: Claude Sonnet 4.6 --- .../no-mutation-invalidation-leak.test.ts | 4 + .../web/src/app/api/competitors/[id]/route.ts | 68 ++++++ .../competitors/__tests__/competitors.test.ts | 217 ++++++++++++++++++ apps/web/src/app/api/competitors/route.ts | 60 +++++ 4 files changed, 349 insertions(+) create mode 100644 apps/web/src/app/api/competitors/[id]/route.ts create mode 100644 apps/web/src/app/api/competitors/__tests__/competitors.test.ts create mode 100644 apps/web/src/app/api/competitors/route.ts diff --git a/apps/web/src/__tests__/no-mutation-invalidation-leak.test.ts b/apps/web/src/__tests__/no-mutation-invalidation-leak.test.ts index 289c800d..210ce40f 100644 --- a/apps/web/src/__tests__/no-mutation-invalidation-leak.test.ts +++ b/apps/web/src/__tests__/no-mutation-invalidation-leak.test.ts @@ -37,6 +37,10 @@ const ALLOWED: { match: string; why: string }[] = [ match: "mcp/oauth/callback", why: "Completes an MCP OAuth handshake and re-probes the connection, invalidating only `mcp-connections` (non-financial). Same rationale as mcp/connections — no financial data changed.", }, + { + match: "competitors", + why: "Competitor CRUD invalidates only the non-financial `competitors` cache (competitor list UI). No financial metric depends on competitor data and there is no MutationSource for competitors, so trackDataMutation must NOT fire — bumping it would start a bogus insight-regeneration grace countdown for changes that affect no financial compute.", + }, ]; function isAllowed(rel: string): boolean { diff --git a/apps/web/src/app/api/competitors/[id]/route.ts b/apps/web/src/app/api/competitors/[id]/route.ts new file mode 100644 index 00000000..8a0b5be2 --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { z } from "zod"; +import { getCompetitor, updateCompetitor, deleteCompetitor } from "@burnless/db"; +import { requireCompanyAccess, requireCompanyWrite, parseBody, errorResponse, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +const updateCompetitorSchema = z.object({ + name: z.string().min(1).optional(), + url: z.string().url().optional(), + status: z.enum(["active", "paused"]).optional(), +}); + +export const GET = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyAccess(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const competitor = await getCompetitor(id, ctx.companyId); + if (!competitor) return errorResponse("Competitor not found", 404); + return NextResponse.json({ competitor }); +}); + +export const PATCH = withErrorHandler(async ( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const parsed = await parseBody(request, updateCompetitorSchema); + if ("error" in parsed) return parsed.error; + + const competitor = await updateCompetitor(id, ctx.companyId, parsed.data); + if (!competitor) return errorResponse("Competitor not found", 404); + + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ competitor }); +}); + +export const DELETE = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const existing = await getCompetitor(id, ctx.companyId); + if (!existing) return errorResponse("Competitor not found", 404); + + await deleteCompetitor(id, ctx.companyId); + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ ok: true }); +}); diff --git a/apps/web/src/app/api/competitors/__tests__/competitors.test.ts b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts new file mode 100644 index 00000000..f40f2621 --- /dev/null +++ b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for GET /api/competitors and POST /api/competitors. + * Highest-value assertion: domain gate returns 403 DOMAIN_DISABLED. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +const { mockRequireCompanyAccess, mockRequireWrite } = vi.hoisted(() => ({ + mockRequireCompanyAccess: vi.fn(), + mockRequireWrite: vi.fn(), +})); + +const { mockRequireDomainEnabled } = vi.hoisted(() => ({ + mockRequireDomainEnabled: vi.fn(), +})); + +const { mockListCompetitors, mockCreateCompetitor, mockCreateSource } = vi.hoisted(() => ({ + mockListCompetitors: vi.fn(), + mockCreateCompetitor: vi.fn(), + mockCreateSource: vi.fn(), +})); + +vi.mock("@/lib/api-helpers", () => ({ + requireCompanyAccess: mockRequireCompanyAccess, + requireCompanyWrite: mockRequireWrite, + parseBody: async (req: Request, schema: { parse: (d: unknown) => unknown }) => { + try { + return { data: schema.parse(await req.json()) }; + } catch { + return { error: NextResponse.json({ error: "Validation failed" }, { status: 400 }) }; + } + }, + errorResponse: (msg: string, status: number) => NextResponse.json({ error: msg }, { status }), + withErrorHandler: (fn: (...args: unknown[]) => unknown) => fn, +})); + +vi.mock("@/lib/domain-gating", () => ({ + requireDomainEnabled: mockRequireDomainEnabled, +})); + +vi.mock("@burnless/db", () => ({ + listCompetitors: mockListCompetitors, + createCompetitor: mockCreateCompetitor, + createSource: mockCreateSource, +})); + +vi.mock("next/cache", () => ({ revalidateTag: vi.fn() })); + +import { GET, POST } from "../route"; + +const validCtx = { userId: "user-1", companyId: "company-1", role: "editor" } as const; + +function makeRequest(url: string, options?: RequestInit): Request { + return new Request(url, options); +} + +describe("GET /api/competitors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when unauthenticated", async () => { + mockRequireCompanyAccess.mockResolvedValue({ + error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + }); + + const res = await GET(makeRequest("http://localhost/api/competitors")); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 403 DOMAIN_DISABLED when competitor domain is off", async () => { + mockRequireCompanyAccess.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue( + NextResponse.json( + { error: "This domain is not available on this deployment", code: "DOMAIN_DISABLED", domainId: "competitor" }, + { status: 403 }, + ), + ); + + const res = await GET(makeRequest("http://localhost/api/competitors")); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.code).toBe("DOMAIN_DISABLED"); + expect(mockListCompetitors).not.toHaveBeenCalled(); + }); + + it("returns competitors list when domain is enabled", async () => { + mockRequireCompanyAccess.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockListCompetitors.mockResolvedValue([ + { id: "c-1", name: "Acme", url: "https://acme.com", status: "active" }, + ]); + + const res = await GET(makeRequest("http://localhost/api/competitors")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.competitors).toHaveLength(1); + expect(body.competitors[0].name).toBe("Acme"); + expect(mockListCompetitors).toHaveBeenCalledWith("company-1"); + }); +}); + +describe("POST /api/competitors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 403 DOMAIN_DISABLED when competitor domain is off", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue( + NextResponse.json( + { error: "This domain is not available on this deployment", code: "DOMAIN_DISABLED", domainId: "competitor" }, + { status: 403 }, + ), + ); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Rival", url: "https://rival.com" }), + }), + ); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.code).toBe("DOMAIN_DISABLED"); + expect(mockCreateCompetitor).not.toHaveBeenCalled(); + }); + + it("creates competitor and returns 201", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + status: "active", + }); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Rival", url: "https://rival.com" }), + }), + ); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.competitor.id).toBe("c-new"); + expect(mockCreateCompetitor).toHaveBeenCalledWith({ + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + }); + expect(mockCreateSource).not.toHaveBeenCalled(); + }); + + it("creates competitor with sources when provided", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + status: "active", + }); + mockCreateSource.mockResolvedValue({ id: "src-1" }); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Rival", + url: "https://rival.com", + sources: [{ type: "pricing", url: "https://rival.com/pricing" }], + }), + }), + ); + + expect(res.status).toBe(201); + expect(mockCreateSource).toHaveBeenCalledWith({ + companyId: "company-1", + competitorId: "c-new", + type: "pricing", + url: "https://rival.com/pricing", + config: undefined, + }); + }); + + it("returns 400 for invalid body (missing url)", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Rival" }), + }), + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBeTruthy(); + expect(mockCreateCompetitor).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/competitors/route.ts b/apps/web/src/app/api/competitors/route.ts new file mode 100644 index 00000000..7a134a21 --- /dev/null +++ b/apps/web/src/app/api/competitors/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { z } from "zod"; +import { createCompetitor, listCompetitors, createSource } from "@burnless/db"; +import { requireCompanyAccess, requireCompanyWrite, parseBody, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +const createCompetitorSchema = z.object({ + name: z.string().min(1), + url: z.string().url(), + sources: z + .array( + z.object({ + type: z.enum(["pricing", "social"]), + url: z.string().url(), + config: z.unknown().optional(), + }), + ) + .optional(), +}); + +export const GET = withErrorHandler(async (_request: Request) => { + const ctx = await requireCompanyAccess(); + if ("error" in ctx) return ctx.error; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const competitors = await listCompetitors(ctx.companyId); + return NextResponse.json({ competitors }); +}); + +export const POST = withErrorHandler(async (request: Request) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const parsed = await parseBody(request, createCompetitorSchema); + if ("error" in parsed) return parsed.error; + + const { name, url, sources } = parsed.data; + const competitor = await createCompetitor({ companyId: ctx.companyId, name, url }); + + if (sources && sources.length > 0) { + for (const source of sources) { + await createSource({ + companyId: ctx.companyId, + competitorId: competitor.id, + type: source.type, + url: source.url, + config: source.config, + }); + } + } + + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ competitor }, { status: 201 }); +}); From 8d8c3712b11ee76272a3d51865182c5cc5ee8599 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:13:22 +0530 Subject: [PATCH 14/55] feat(competitor): sources/sync/changes API --- .../[id]/changes/[cid]/ack/route.ts | 21 +++++++ .../app/api/competitors/[id]/changes/route.ts | 23 ++++++++ .../competitors/[id]/sources/[sid]/route.ts | 50 +++++++++++++++++ .../app/api/competitors/[id]/sources/route.ts | 56 +++++++++++++++++++ .../app/api/competitors/[id]/sync/route.ts | 28 ++++++++++ 5 files changed, 178 insertions(+) create mode 100644 apps/web/src/app/api/competitors/[id]/changes/[cid]/ack/route.ts create mode 100644 apps/web/src/app/api/competitors/[id]/changes/route.ts create mode 100644 apps/web/src/app/api/competitors/[id]/sources/[sid]/route.ts create mode 100644 apps/web/src/app/api/competitors/[id]/sources/route.ts create mode 100644 apps/web/src/app/api/competitors/[id]/sync/route.ts diff --git a/apps/web/src/app/api/competitors/[id]/changes/[cid]/ack/route.ts b/apps/web/src/app/api/competitors/[id]/changes/[cid]/ack/route.ts new file mode 100644 index 00000000..deb70f6a --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/changes/[cid]/ack/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { ackChange } from "@burnless/db"; +import { requireCompanyWrite, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +export const POST = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string; cid: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { cid } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + await ackChange(cid, ctx.companyId, new Date()); + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ ok: true }); +}); diff --git a/apps/web/src/app/api/competitors/[id]/changes/route.ts b/apps/web/src/app/api/competitors/[id]/changes/route.ts new file mode 100644 index 00000000..6dc4e3c7 --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/changes/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { listChanges } from "@burnless/db"; +import { requireCompanyAccess, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +export const GET = withErrorHandler(async ( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyAccess(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const limit = limitParam ? Math.max(1, parseInt(limitParam, 10)) : 50; + + const changes = await listChanges(ctx.companyId, { competitorId: id, limit }); + return NextResponse.json({ changes }); +}); diff --git a/apps/web/src/app/api/competitors/[id]/sources/[sid]/route.ts b/apps/web/src/app/api/competitors/[id]/sources/[sid]/route.ts new file mode 100644 index 00000000..c15d3fff --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/sources/[sid]/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { z } from "zod"; +import { updateSource, deleteSource } from "@burnless/db"; +import { requireCompanyWrite, parseBody, errorResponse, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +const updateSourceSchema = z.object({ + url: z.string().url().optional(), + config: z.unknown().optional(), + enabled: z.boolean().optional(), + intervalHours: z.number().int().positive().optional(), +}); + +export const PATCH = withErrorHandler(async ( + request: Request, + { params }: { params: Promise<{ id: string; sid: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { sid } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const parsed = await parseBody(request, updateSourceSchema); + if ("error" in parsed) return parsed.error; + + const source = await updateSource(sid, ctx.companyId, parsed.data); + if (!source) return errorResponse("Source not found", 404); + + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ source }); +}); + +export const DELETE = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string; sid: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { sid } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + await deleteSource(sid, ctx.companyId); + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ ok: true }); +}); diff --git a/apps/web/src/app/api/competitors/[id]/sources/route.ts b/apps/web/src/app/api/competitors/[id]/sources/route.ts new file mode 100644 index 00000000..b1d8a6e7 --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/sources/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { z } from "zod"; +import { listSources, createSource } from "@burnless/db"; +import { requireCompanyAccess, requireCompanyWrite, parseBody, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; + +const createSourceSchema = z.object({ + type: z.enum(["pricing", "social"]), + url: z.string().url(), + config: z.unknown().optional(), + intervalHours: z.number().int().positive().optional(), +}); + +export const GET = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyAccess(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const sources = await listSources(id, ctx.companyId); + return NextResponse.json({ sources }); +}); + +export const POST = withErrorHandler(async ( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const parsed = await parseBody(request, createSourceSchema); + if ("error" in parsed) return parsed.error; + + const { type, url, config, intervalHours } = parsed.data; + const source = await createSource({ + companyId: ctx.companyId, + competitorId: id, + type, + url, + config, + intervalHours, + }); + + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ source }, { status: 201 }); +}); diff --git a/apps/web/src/app/api/competitors/[id]/sync/route.ts b/apps/web/src/app/api/competitors/[id]/sync/route.ts new file mode 100644 index 00000000..2e0f3b11 --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/sync/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { listSources } from "@burnless/db"; +import { requireCompanyWrite, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; +import { runSource } from "@/lib/competitor/pipeline"; + +export const POST = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const sources = await listSources(id, ctx.companyId); + const enabled = sources.filter((s) => s.enabled); + + let changed = 0; + for (const s of enabled) { + const r = await runSource(s); + if (r.changed) changed++; + } + + return NextResponse.json({ ran: enabled.length, changed }); +}); From a4f660b6bd6926fc5c3a608fbb2640fdf6c8495a Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:20:30 +0530 Subject: [PATCH 15/55] feat(competitor): SWR hooks (live-updating via mutation bus) - Add COMPETITOR_DOMAINS set to mutation-bus (separate from FINANCIAL_DOMAINS so competitor syncs don't reset the AI-insight stale countdown) - Map /competitors URLs to "competitor" domain in domainFromUrl - Update apiFetch to publish competitor-domain mutations on the bus - Add KEYS.competitors + KEYS.competitorChanges to the key registry - New apps/web/src/lib/swr/competitor.ts: useCompetitors + useCompetitorChanges both subscribe to subscribeMutation, mirroring useTransactions (WS2 pattern) - Re-export hooks + DTO types from @/lib/swr index Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/api-fetch.ts | 13 ++-- apps/web/src/lib/mutation-bus.ts | 8 +++ apps/web/src/lib/swr/competitor.ts | 103 +++++++++++++++++++++++++++++ apps/web/src/lib/swr/index.ts | 10 +++ apps/web/src/lib/swr/keys.ts | 4 ++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/swr/competitor.ts diff --git a/apps/web/src/lib/api-fetch.ts b/apps/web/src/lib/api-fetch.ts index 3fd1a8ab..88bf316e 100644 --- a/apps/web/src/lib/api-fetch.ts +++ b/apps/web/src/lib/api-fetch.ts @@ -10,7 +10,7 @@ * so reintroduces a second, drift-prone source (a stale server-rendered prop or * a per-tab sessionStorage value) and causes spurious 409 ScenarioSafetyErrors. */ -import { publishMutation, domainFromUrl, FINANCIAL_DOMAINS } from "./mutation-bus"; +import { publishMutation, domainFromUrl, FINANCIAL_DOMAINS, COMPETITOR_DOMAINS } from "./mutation-bus"; const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]); @@ -31,10 +31,13 @@ export async function apiFetch( try { const method = (init?.method ?? "GET").toUpperCase(); const domain = domainFromUrl(url); - // Emit only financial-data mutations. Non-financial endpoints (notably the - // insights regen POST itself) map to "other" and are NOT emitted — this is what - // prevents the auto-regen from retriggering the badge/countdown in a loop. - if (res.ok && MUTATING.has(method) && FINANCIAL_DOMAINS.has(domain)) { + // Emit financial-data mutations AND competitor mutations. Non-financial endpoints + // (notably the insights regen POST itself) map to "other" and are NOT emitted — + // this is what prevents the auto-regen from retriggering the badge/countdown in a + // loop. Competitor mutations are kept separate from FINANCIAL_DOMAINS so competitor + // syncs do not reset the AI-insight stale countdown. + if (res.ok && MUTATING.has(method) && + (FINANCIAL_DOMAINS.has(domain) || COMPETITOR_DOMAINS.has(domain))) { publishMutation({ domain, method, at: Date.now() }); } } catch { diff --git a/apps/web/src/lib/mutation-bus.ts b/apps/web/src/lib/mutation-bus.ts index 4507a7cc..94e874ea 100644 --- a/apps/web/src/lib/mutation-bus.ts +++ b/apps/web/src/lib/mutation-bus.ts @@ -31,6 +31,13 @@ export const FINANCIAL_DOMAINS = new Set([ "scenario", ]); +/** + * Non-financial domains that are still published on the bus so their SWR hooks + * can live-update (same-tab AND cross-tab). Kept separate from FINANCIAL_DOMAINS + * so competitor syncs do NOT reset the AI-insight stale countdown. + */ +export const COMPETITOR_DOMAINS = new Set(["competitor"]); + type Handler = (e: MutationEvent) => void; const handlers = new Set(); let storageBound = false; @@ -83,6 +90,7 @@ export function domainFromUrl(url: string): string { if (url.includes("/revenue-streams")) return "revenue"; if (url.includes("/funding-rounds")) return "funding"; if (url.includes("/scenarios")) return "scenario"; + if (url.includes("/competitors")) return "competitor"; // Non-financial (insights regen POST, chat, ai-config, preferences, …) → "other", // which apiFetch does NOT emit. Critically this keeps the insights regen POST out of // the bus, so auto-regen can't retrigger itself into an infinite loop. diff --git a/apps/web/src/lib/swr/competitor.ts b/apps/web/src/lib/swr/competitor.ts new file mode 100644 index 00000000..251d6a27 --- /dev/null +++ b/apps/web/src/lib/swr/competitor.ts @@ -0,0 +1,103 @@ +"use client"; + +/** + * SWR hooks for the competitor-analysis domain. + * + * Live-update mechanics mirror useTransactions (WS2 lesson): both hooks + * subscribe to the mutation bus and call mutate() when a competitor-domain + * event fires (same-tab AND cross-tab). The competitor domain is kept separate + * from FINANCIAL_DOMAINS so competitor syncs do NOT reset the AI-insight stale + * countdown — only the SWR client cache is refreshed. + */ + +import { useEffect } from "react"; +import useSWR, { type SWRConfiguration } from "swr"; +import { KEYS } from "./keys"; +import { subscribeMutation, COMPETITOR_DOMAINS } from "@/lib/mutation-bus"; + +// ── DTO types (JSON-safe: Date → ISO string) ───────────────────────────────── + +/** A competitor row as returned by GET /api/competitors (JSON: Date → ISO string). */ +export interface CompetitorDto { + id: string; + companyId: string; + name: string; + url: string; + /** "active" | "paused" */ + status: string; + createdAt: string; + updatedAt: string; +} + +/** A competitor change row as returned by GET /api/competitors/[id]/changes. */ +export interface CompetitorChangeDto { + id: string; + competitorId: string; + sourceId: string; + snapshotId: string; + companyId: string; + detectedAt: string; + changeType: string; + summary: string; + before: Record | null; + after: Record | null; + /** "info" | "warning" | "critical" */ + severity: string; + acknowledgedAt: string | null; + createdAt: string; +} + +/** Payload shape returned by GET /api/competitors. */ +export interface CompetitorsPayload { + competitors: CompetitorDto[]; +} + +/** Payload shape returned by GET /api/competitors/[id]/changes. */ +export interface CompetitorChangesPayload { + changes: CompetitorChangeDto[]; +} + +// ── Hooks ───────────────────────────────────────────────────────────────────── + +/** + * All competitors for the current company. + * + * Live-updates after add/delete/sync via the competitor mutation bus — any + * successful POST/PATCH/DELETE to /api/competitors* triggers a refetch here + * (same-tab AND cross-tab), mirroring the useTransactions pattern from WS2. + */ +export function useCompetitors( + config?: SWRConfiguration, +) { + const swr = useSWR(KEYS.competitors, { ...config }); + const { mutate } = swr; + useEffect(() => { + return subscribeMutation((e) => { + if (COMPETITOR_DOMAINS.has(e.domain)) void mutate(); + }); + }, [mutate]); + return swr; +} + +/** + * Detected changes for a single competitor, newest-first. + * + * Pass `competitorId` to enable the fetch; omit (or pass undefined/null) to + * suspend fetching (SWR null-key pattern). Live-updates on competitor-domain + * mutations so the changes list refreshes after a sync completes. + */ +export function useCompetitorChanges( + competitorId?: string | null, + config?: SWRConfiguration, +) { + const key = competitorId ? KEYS.competitorChanges(competitorId) : null; + const swr = useSWR(key, { ...config }); + const { mutate } = swr; + useEffect(() => { + if (!competitorId) return; + return subscribeMutation((e) => { + if (COMPETITOR_DOMAINS.has(e.domain)) void mutate(); + }); + }, [competitorId, mutate]); + return swr; +} diff --git a/apps/web/src/lib/swr/index.ts b/apps/web/src/lib/swr/index.ts index 8857e8ea..b305d4b6 100644 --- a/apps/web/src/lib/swr/index.ts +++ b/apps/web/src/lib/swr/index.ts @@ -7,6 +7,16 @@ export { SWRProvider } from "./provider"; export { KEYS } from "./keys"; +export { + useCompetitors, + useCompetitorChanges, +} from "./competitor"; +export type { + CompetitorDto, + CompetitorChangeDto, + CompetitorsPayload, + CompetitorChangesPayload, +} from "./competitor"; export { fetcher, FetchError } from "./fetcher"; export { useScenarios, diff --git a/apps/web/src/lib/swr/keys.ts b/apps/web/src/lib/swr/keys.ts index 7e0a284a..623523a1 100644 --- a/apps/web/src/lib/swr/keys.ts +++ b/apps/web/src/lib/swr/keys.ts @@ -72,6 +72,10 @@ export const KEYS = { sessionDisabledTools: (conversationId: string) => `/api/chat/session-tools?conversationId=${conversationId}`, + // Competitor analysis domain + competitors: "/api/competitors", + competitorChanges: (id: string) => `/api/competitors/${id}/changes`, + // AI providers manager (Settings → AI Providers, #49 P3) aiProviders: "/api/ai-features/providers", aiProvider: (id: string) => `/api/ai-features/providers/${id}`, From 7aa7b2ba0d20532c0c3aa8f601e76dc481e3cfb8 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:29:51 +0530 Subject: [PATCH 16/55] feat(competitor): competitors list + add UI Co-Authored-By: Claude Opus 4.8 (1M context) --- .../competitors/competitors-view.tsx | 283 ++++++++++++++++++ .../src/app/(dashboard)/competitors/page.tsx | 49 +++ 2 files changed, 332 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/competitors/competitors-view.tsx create mode 100644 apps/web/src/app/(dashboard)/competitors/page.tsx diff --git a/apps/web/src/app/(dashboard)/competitors/competitors-view.tsx b/apps/web/src/app/(dashboard)/competitors/competitors-view.tsx new file mode 100644 index 00000000..9e4b06ed --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/competitors-view.tsx @@ -0,0 +1,283 @@ +"use client"; + +/** + * Competitors browse + manage view (Task 14). Lightweight DataTable (not the + * metric grid), mirroring the WS2 transactions-view pattern: SSR-seeded SWR + * (useCompetitors fallbackData) that live-updates via the competitor mutation + * bus, an "Add competitor" Modal, and useConfirm-gated delete. No currency on + * this surface. + */ + +import { useState } from "react"; +import Link from "next/link"; +import { Swords, Trash2, ExternalLink } from "lucide-react"; +import { + DataTable, + Button, + Modal, + Input, + DataEmptyState, + useConfirm, +} from "@/components/ui"; +import { apiFetch } from "@/lib/api-fetch"; +import { toUserMessage } from "@/lib/api-error"; +import { useCompetitors, type CompetitorDto, type CompetitorsPayload } from "@/lib/swr"; + +interface CompetitorsViewProps { + initialData: CompetitorsPayload; +} + +/** Reuse-only status pill — no @/components/ui badge component exists, so a + * token-styled (matches the transactions SourcePill precedent). NOT a + * new component. */ +function StatusPill({ status }: { status: string }) { + const cls = + status === "active" + ? "bg-success-50 text-success-700" + : "bg-surface-100 text-surface-600"; + return ( + + {status} + + ); +} + +export function CompetitorsView({ initialData }: CompetitorsViewProps) { + const { data, mutate } = useCompetitors({ fallbackData: initialData }); + const { confirm, dialog } = useConfirm(); + + const [adding, setAdding] = useState(false); + const [actionError, setActionError] = useState(null); + + const rows = data?.competitors ?? []; + + async function handleDelete(row: CompetitorDto) { + const ok = await confirm({ + title: "Delete competitor", + body: `Delete "${row.name}"? This removes the competitor and its tracked sources and changes.`, + confirmLabel: "Delete", + destructive: true, + }); + if (!ok) return; + setActionError(null); + try { + const res = await apiFetch(`/api/competitors/${row.id}`, { method: "DELETE" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to delete competitor"); + } + void mutate(); + } catch (err) { + setActionError(toUserMessage(err)); + } + } + + const columns = [ + { + key: "name", + header: "Name", + render: (r: CompetitorDto) => ( + + {r.name} + + ), + sortValue: (r: CompetitorDto) => r.name.toLowerCase(), + }, + { + key: "url", + header: "URL", + render: (r: CompetitorDto) => ( + e.stopPropagation()} + > + {r.url} + + + ), + }, + { + key: "status", + header: "Status", + render: (r: CompetitorDto) => , + sortValue: (r: CompetitorDto) => r.status, + }, + { + key: "actions", + header: "", + align: "right" as const, + render: (r: CompetitorDto) => ( +
+ +
+ ), + }, + ]; + + return ( +
+
+
+

Competitors

+

+ Track competitors and get notified when their pricing or positioning changes +

+
+
+ +
+
+ + {actionError && ( +
+ {actionError} +
+ )} + + {rows.length === 0 ? ( + setAdding(true)}>Add competitor} + /> + ) : ( +
+ r.id} + emptyMessage="No competitors yet. Add one to start tracking." + /> +
+ )} + + {adding && ( + setAdding(false)} + onAdded={() => { + setAdding(false); + void mutate(); + }} + /> + )} + + {dialog} +
+ ); +} + +// ── Add competitor modal ─────────────────────────────────────────────────────── + +function AddCompetitorModal({ + open, + onClose, + onAdded, +}: { + open: boolean; + onClose: () => void; + onAdded: () => void; +}) { + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [pricingUrl, setPricingUrl] = useState(""); + const [socialUrl, setSocialUrl] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const sources = [ + ...(pricingUrl ? [{ type: "pricing" as const, url: pricingUrl }] : []), + ...(socialUrl ? [{ type: "social" as const, url: socialUrl }] : []), + ]; + const res = await apiFetch("/api/competitors", { + method: "POST", + body: JSON.stringify({ name, url, sources }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to add competitor"); + } + onAdded(); + } catch (err) { + setError(toUserMessage(err)); + } finally { + setSubmitting(false); + } + } + + return ( + +
+ setName(e.target.value)} + placeholder="Acme Inc." + autoFocus + /> + setUrl(e.target.value)} + placeholder="https://acme.com" + /> + setPricingUrl(e.target.value)} + placeholder="https://acme.com/pricing" + hint="We'll watch this page for pricing changes." + /> + setSocialUrl(e.target.value)} + placeholder="https://x.com/acme" + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/competitors/page.tsx b/apps/web/src/app/(dashboard)/competitors/page.tsx new file mode 100644 index 00000000..6548f2ea --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/page.tsx @@ -0,0 +1,49 @@ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { listCompetitors } from "@burnless/db"; +import { getCompany } from "@/lib/data"; +import { isDomainEnabled } from "@/lib/domain-gating"; +import { SetupPrompt } from "@/components/ui/empty-state"; +import { ReportContentSkeleton } from "@/components/reports/report-skeleton"; +import { CompetitorsView } from "./competitors-view"; +import type { CompetitorsPayload } from "@/lib/swr"; + +export default async function CompetitorsPage() { + const company = await getCompany(); + if (!company) return ; + + // Page-level domain gate — mirrors the requireDomainEnabled guard the REST + // routes use. If the competitor domain is off for this company/deployment, + // the route 404s (same surface the disabled nav entry implies). + if (!(await isDomainEnabled("competitor", { companyId: company.id }))) { + notFound(); + } + + return ( + }> + + + ); +} + +async function CompetitorsContent({ companyId }: { companyId: string }) { + const competitors = await listCompetitors(companyId); + // Shape to the JSON-safe DTO the SWR hook serves (Date → ISO string) so the + // SSR seed matches the client fetch exactly and fallbackData applies cleanly. + const initialData: CompetitorsPayload = { + competitors: competitors.map((c) => ({ + id: c.id, + companyId: c.companyId, + name: c.name, + url: c.url, + status: c.status, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + })), + }; + + return ; +} From acb8855931c518db86111b5bd96d828bd981219f Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:41:46 +0530 Subject: [PATCH 17/55] feat(competitor): competitor profile + change timeline UI Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[id]/competitor-profile-view.tsx | 326 ++++++++++++++++++ .../app/(dashboard)/competitors/[id]/page.tsx | 123 +++++++ 2 files changed, 449 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx create mode 100644 apps/web/src/app/(dashboard)/competitors/[id]/page.tsx diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx new file mode 100644 index 00000000..bd206846 --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -0,0 +1,326 @@ +"use client"; + +/** + * Competitor profile + change timeline view (Task 15). Nested route under + * /competitors — mirrors the cap-table / transactions-accounts nesting + * precedent (back-link header) and the Task-14 competitors-view conventions: + * design-system components only, SSR-seeded SWR (useCompetitorChanges + * fallbackData) for a live-updating timeline, and inline token-styled status + * spans following the StatusPill precedent (no new badge component). + * + * Pricing snapshots are competitor-scraped data; prices are rendered ONLY via + * useLocale().fmtCurrency (the company's configured currency/locale) — never a + * hardcoded symbol. See the report for the per-plan-currency note: fmtCurrency + * formats in the company currency by design (the hook takes no currency arg). + */ + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, ExternalLink, RefreshCw } from "lucide-react"; +import { DataTable, Button, WidgetCard } from "@/components/ui"; +import { useLocale } from "@/components/locale/locale-context"; +import { apiFetch } from "@/lib/api-fetch"; +import { toUserMessage } from "@/lib/api-error"; +import { + useCompetitorChanges, + type CompetitorChangeDto, + type CompetitorChangesPayload, +} from "@/lib/swr"; + +// ── JSON-safe prop DTOs (Date → ISO string; mirrors the list-page DTO style) ── + +export interface CompetitorProfileDto { + id: string; + name: string; + url: string; + status: string; +} + +export interface SourceDto { + id: string; + /** "pricing" | "social" */ + type: string; + url: string; + enabled: boolean; + lastRunAt: string | null; + lastStatus: string | null; + /** "ok" | "broken" */ + healthState: string; +} + +interface PlanDto { + name: string; + price: { amount: number | null; currency: string | null; period: string | null }; + features: string[]; +} + +export interface SnapshotDto { + id: string; + sourceId: string; + capturedAt: string; + structured: { plans?: PlanDto[] } | null; +} + +interface CompetitorProfileViewProps { + competitor: CompetitorProfileDto; + sources: SourceDto[]; + /** Latest snapshot per source, aligned by index with `sources`. */ + latestSnapshots: (SnapshotDto | null)[]; + initialChanges: CompetitorChangesPayload; +} + +// ── Inline token-styled status spans (StatusPill precedent — NOT new +// @/components/ui components; same pattern as the Task-14 list view). ─────── + +const PILL_BASE = + "inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium uppercase"; + +/** Source health: "broken" → danger ("Needs attention"), else success ("Healthy"). */ +function HealthBadge({ + healthState, + lastStatus, +}: { + healthState: string; + lastStatus: string | null; +}) { + if (healthState === "broken") { + return ( + + Needs attention + + ); + } + return Healthy; +} + +/** Change severity: critical → danger, warning → warning, else (info) → surface. */ +function SeverityBadge({ severity }: { severity: string }) { + const cls = + severity === "critical" + ? "bg-danger-50 text-danger-600" + : severity === "warning" + ? "bg-warning-50 text-warning-700" + : "bg-surface-100 text-surface-600"; + return {severity}; +} + +// ── View ──────────────────────────────────────────────────────────────────── + +export function CompetitorProfileView({ + competitor, + sources, + latestSnapshots, + initialChanges, +}: CompetitorProfileViewProps) { + const router = useRouter(); + const { fmtCurrency, fmtDate } = useLocale(); + const { data, mutate } = useCompetitorChanges(competitor.id, { + fallbackData: initialChanges, + }); + + const [syncing, setSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); + + const changes = data?.changes ?? initialChanges.changes; + + // First pricing source that produced a snapshot with plans → the headline + // pricing card. Snapshots are aligned by index with `sources`. + const pricingPlans = (() => { + for (let i = 0; i < sources.length; i++) { + if (sources[i]?.type !== "pricing") continue; + const plans = latestSnapshots[i]?.structured?.plans; + if (plans && plans.length > 0) return plans; + } + return null; + })(); + + async function handleSync() { + setSyncError(null); + setSyncing(true); + try { + const res = await apiFetch(`/api/competitors/${competitor.id}/sync`, { + method: "POST", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to sync"); + } + // Refresh the live changes timeline AND the RSC-sourced source health / + // last-checked / latest-snapshot props. + void mutate(); + router.refresh(); + } catch (err) { + setSyncError(toUserMessage(err)); + } finally { + setSyncing(false); + } + } + + const changeColumns = [ + { + key: "severity", + header: "Severity", + render: (c: CompetitorChangeDto) => , + sortValue: (c: CompetitorChangeDto) => c.severity, + }, + { + key: "summary", + header: "Change", + render: (c: CompetitorChangeDto) => ( + {c.summary} + ), + }, + { + key: "detectedAt", + header: "Detected", + align: "right" as const, + render: (c: CompetitorChangeDto) => ( + {fmtDate(c.detectedAt)} + ), + sortValue: (c: CompetitorChangeDto) => c.detectedAt, + }, + ]; + + const planColumns = [ + { + key: "name", + header: "Plan", + render: (p: PlanDto) => {p.name}, + }, + { + key: "price", + header: "Price", + align: "right" as const, + render: (p: PlanDto) => ( + + {p.price.amount != null ? fmtCurrency(p.price.amount) : "—"} + {p.price.period ? ( + {` / ${p.price.period}`} + ) : null} + + ), + }, + ]; + + return ( +
+
+ + + Back to Competitors + +
+
+

+ {competitor.name} +

+ + {competitor.url} + + +
+
+
+ + {syncError && ( +
+ {syncError} +
+ )} + + {/* Sources ─────────────────────────────────────────────────────────── */} +
+

Sources

+ {sources.length === 0 ? ( +

No sources tracked for this competitor.

+ ) : ( +
+ {sources.map((source) => ( + +
+
+
+ + {source.type} + + +
+ + {source.url} + + +

+ {source.lastRunAt + ? `Last checked ${fmtDate(source.lastRunAt)}` + : "Never checked"} +

+
+ +
+
+ ))} +
+ )} +
+ + {/* Latest pricing snapshot ─────────────────────────────────────────── */} + {pricingPlans && ( +
+

Latest pricing

+
+ p.name} + emptyMessage="No pricing plans captured." + /> +
+
+ )} + + {/* Change timeline ─────────────────────────────────────────────────── */} +
+

Activity

+
+ c.id} + emptyMessage="No changes detected yet." + /> +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx new file mode 100644 index 00000000..a814ebb7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx @@ -0,0 +1,123 @@ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { + getCompetitor, + listSources, + listChanges, + getLatestSnapshot, +} from "@burnless/db"; +import { getCompany } from "@/lib/data"; +import { isDomainEnabled } from "@/lib/domain-gating"; +import { SetupPrompt } from "@/components/ui/empty-state"; +import { ReportContentSkeleton } from "@/components/reports/report-skeleton"; +import type { CompetitorChangesPayload } from "@/lib/swr"; +import { + CompetitorProfileView, + type CompetitorProfileDto, + type SourceDto, + type SnapshotDto, +} from "./competitor-profile-view"; + +export default async function CompetitorPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const company = await getCompany(); + if (!company) return ; + + // Page-level domain gate — mirrors the requireDomainEnabled guard the REST + // routes use and the Task-14 list page. If the competitor domain is off for + // this company/deployment, the route 404s. + if (!(await isDomainEnabled("competitor", { companyId: company.id }))) { + notFound(); + } + + const competitor = await getCompetitor(id, company.id); + if (!competitor) notFound(); + + return ( + }> + + + ); +} + +async function CompetitorContent({ + companyId, + competitorId, + competitor, +}: { + companyId: string; + competitorId: string; + competitor: NonNullable>>; +}) { + const [sources, changes] = await Promise.all([ + listSources(competitorId, companyId), + listChanges(companyId, { competitorId, limit: 50 }), + ]); + const snapshots = await Promise.all(sources.map((s) => getLatestSnapshot(s.id))); + + // Shape everything to JSON-safe DTOs (Date → ISO string) so the SSR seed + // matches the client SWR fetch exactly (fallbackData applies cleanly) and no + // live Date objects cross the RSC → client boundary. + const competitorDto: CompetitorProfileDto = { + id: competitor.id, + name: competitor.name, + url: competitor.url, + status: competitor.status, + }; + + const sourceDtos: SourceDto[] = sources.map((s) => ({ + id: s.id, + type: s.type, + url: s.url, + enabled: s.enabled, + lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null, + lastStatus: s.lastStatus, + healthState: s.healthState, + })); + + const latestSnapshots: (SnapshotDto | null)[] = snapshots.map((snap) => + snap + ? { + id: snap.id, + sourceId: snap.sourceId, + capturedAt: snap.capturedAt.toISOString(), + structured: snap.structured as SnapshotDto["structured"], + } + : null, + ); + + const initialChanges: CompetitorChangesPayload = { + changes: changes.map((c) => ({ + id: c.id, + competitorId: c.competitorId, + sourceId: c.sourceId, + snapshotId: c.snapshotId, + companyId: c.companyId, + detectedAt: c.detectedAt.toISOString(), + changeType: c.changeType, + summary: c.summary, + before: c.before as Record | null, + after: c.after as Record | null, + severity: c.severity, + acknowledgedAt: c.acknowledgedAt ? c.acknowledgedAt.toISOString() : null, + createdAt: c.createdAt.toISOString(), + })), + }; + + return ( + + ); +} From 3324dfb9cfc0b587add2ae171d11d7cbda1d53d1 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:49:16 +0530 Subject: [PATCH 18/55] fix(competitor): render competitor plan prices in their own currency + drop dead status field --- .../competitors/[id]/competitor-profile-view.tsx | 16 +++++++++------- .../app/(dashboard)/competitors/[id]/page.tsx | 1 - 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx index bd206846..570ce44e 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -8,10 +8,10 @@ * fallbackData) for a live-updating timeline, and inline token-styled status * spans following the StatusPill precedent (no new badge component). * - * Pricing snapshots are competitor-scraped data; prices are rendered ONLY via - * useLocale().fmtCurrency (the company's configured currency/locale) — never a - * hardcoded symbol. See the report for the per-plan-currency note: fmtCurrency - * formats in the company currency by design (the hook takes no currency arg). + * Pricing snapshots are competitor-scraped data; prices are rendered via + * formatCurrency() from @burnless/types using the plan's OWN scraped currency + * (falling back to the company currency) — never a hardcoded symbol and never + * forcing the company currency onto a competitor's price. */ import { useState } from "react"; @@ -20,6 +20,7 @@ import { useRouter } from "next/navigation"; import { ArrowLeft, ExternalLink, RefreshCw } from "lucide-react"; import { DataTable, Button, WidgetCard } from "@/components/ui"; import { useLocale } from "@/components/locale/locale-context"; +import { formatCurrency, type CurrencyCode } from "@burnless/types"; import { apiFetch } from "@/lib/api-fetch"; import { toUserMessage } from "@/lib/api-error"; import { @@ -34,7 +35,6 @@ export interface CompetitorProfileDto { id: string; name: string; url: string; - status: string; } export interface SourceDto { @@ -117,7 +117,7 @@ export function CompetitorProfileView({ initialChanges, }: CompetitorProfileViewProps) { const router = useRouter(); - const { fmtCurrency, fmtDate } = useLocale(); + const { fmtDate, currency, locale } = useLocale(); const { data, mutate } = useCompetitorChanges(competitor.id, { fallbackData: initialChanges, }); @@ -197,7 +197,9 @@ export function CompetitorProfileView({ align: "right" as const, render: (p: PlanDto) => ( - {p.price.amount != null ? fmtCurrency(p.price.amount) : "—"} + {p.price.amount != null + ? formatCurrency(p.price.amount, ((p.price.currency as CurrencyCode) ?? currency), locale) + : "—"} {p.price.period ? ( {` / ${p.price.period}`} ) : null} diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx index a814ebb7..40b79292 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/page.tsx @@ -70,7 +70,6 @@ async function CompetitorContent({ id: competitor.id, name: competitor.name, url: competitor.url, - status: competitor.status, }; const sourceDtos: SourceDto[] = sources.map((s) => ({ From 241ed16a4cfad08aeab9ae901ef6e281086a37fb Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 12:54:15 +0530 Subject: [PATCH 19/55] fix(competitor): guard ?limit= NaN edge in changes route parseInt("abc", 10) returns NaN; Math.max(1, NaN) propagates NaN down to listChanges. Replace with Number.isFinite guard, defaulting to 50 when the param is absent or non-numeric. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/api/competitors/[id]/changes/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/competitors/[id]/changes/route.ts b/apps/web/src/app/api/competitors/[id]/changes/route.ts index 6dc4e3c7..9f38c92f 100644 --- a/apps/web/src/app/api/competitors/[id]/changes/route.ts +++ b/apps/web/src/app/api/competitors/[id]/changes/route.ts @@ -16,7 +16,8 @@ export const GET = withErrorHandler(async ( const url = new URL(request.url); const limitParam = url.searchParams.get("limit"); - const limit = limitParam ? Math.max(1, parseInt(limitParam, 10)) : 50; + const parsed = parseInt(limitParam ?? "", 10); + const limit = Number.isFinite(parsed) ? Math.max(1, parsed) : 50; const changes = await listChanges(ctx.companyId, { competitorId: id, limit }); return NextResponse.json({ changes }); From 696a606a28cb10dddb5f8c923b1a216b7e46fb02 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 13:05:33 +0530 Subject: [PATCH 20/55] fix(competitor): validate scraped plan currency before formatting (guard Intl RangeError) --- .../(dashboard)/competitors/[id]/competitor-profile-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx index 570ce44e..7c135abd 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -20,7 +20,7 @@ import { useRouter } from "next/navigation"; import { ArrowLeft, ExternalLink, RefreshCw } from "lucide-react"; import { DataTable, Button, WidgetCard } from "@/components/ui"; import { useLocale } from "@/components/locale/locale-context"; -import { formatCurrency, type CurrencyCode } from "@burnless/types"; +import { formatCurrency, isValidCurrency, type CurrencyCode } from "@burnless/types"; import { apiFetch } from "@/lib/api-fetch"; import { toUserMessage } from "@/lib/api-error"; import { @@ -198,7 +198,7 @@ export function CompetitorProfileView({ render: (p: PlanDto) => ( {p.price.amount != null - ? formatCurrency(p.price.amount, ((p.price.currency as CurrencyCode) ?? currency), locale) + ? formatCurrency(p.price.amount, (isValidCurrency(p.price.currency ?? "") ? p.price.currency as CurrencyCode : currency), locale) : "—"} {p.price.period ? ( {` / ${p.price.period}`} From 19666c7d6c169d4b503d39a3ac1b47b2412cab33 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Mon, 29 Jun 2026 14:35:43 +0530 Subject: [PATCH 21/55] fix(competitor): guard pricing collector against noisy/priceless false-positive parses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-LD path: filter Products to only those with a numeric offer price; zero priced plans → confidence 0.1. DOM-heuristic path: keep only well-formed blocks (real name ≠ "Plan" + numeric price); 0 or > 8 well-formed plans → confidence 0.1 (noise/empty). Adds two smoke fixtures (pricing-jsonld-noprice, pricing-dom-noise) and three tests. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/fixtures/pricing-dom-noise.html | 17 ++++++++++++++++ .../fixtures/pricing-jsonld-noprice.html | 3 +++ .../collectors/__tests__/pricing.test.ts | 16 +++++++++++++++ .../src/lib/competitor/collectors/pricing.ts | 20 ++++++++++++++++--- 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom-noise.html create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld-noprice.html diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom-noise.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom-noise.html new file mode 100644 index 00000000..54e8bc84 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-dom-noise.html @@ -0,0 +1,17 @@ + +
$0.40
+
$0.01
+
$0.10
+
$1.20
+
$0.05
+
$2.50
+
$0.80
+
$0.15
+
$0.30
+
$3.00
+
$0.60
+
$0.20
+
$5.00
+
$0.90
+
$1.00
+ diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld-noprice.html b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld-noprice.html new file mode 100644 index 00000000..e76c1e31 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/pricing-jsonld-noprice.html @@ -0,0 +1,3 @@ + + +

Pricing

diff --git a/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts index b5a7196b..d9556484 100644 --- a/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts +++ b/apps/web/src/lib/competitor/collectors/__tests__/pricing.test.ts @@ -27,4 +27,20 @@ describe("pricingCollector.parse", () => { const r = pricingCollector.parse(cap(fx("pricing-broken.html")), src); expect(r.confidence).toBeLessThan(0.4); }); + + it("returns low confidence for JSON-LD Product without an offer price (no real tiers)", () => { + const r = pricingCollector.parse(cap(fx("pricing-jsonld-noprice.html")), src); + expect(r.confidence).toBeLessThan(0.4); + }); + + it("returns low confidence for noisy DOM with many unnamed/implausible plan blocks", () => { + const r = pricingCollector.parse(cap(fx("pricing-dom-noise.html")), src); + expect(r.confidence).toBeLessThan(0.4); + }); + + it("still parses a clean 2-tier DOM pricing page with moderate confidence", () => { + const r = pricingCollector.parse(cap(fx("pricing-dom.html")), src); + expect((r.structured as any).plans.length).toBeGreaterThan(0); + expect(r.confidence).toBeGreaterThan(0.3); + }); }); diff --git a/apps/web/src/lib/competitor/collectors/pricing.ts b/apps/web/src/lib/competitor/collectors/pricing.ts index 9ff16375..0c2104f8 100644 --- a/apps/web/src/lib/competitor/collectors/pricing.ts +++ b/apps/web/src/lib/competitor/collectors/pricing.ts @@ -58,14 +58,28 @@ function fromDomHeuristic(html: string): Plan[] { return plans; } +/** A plan is "well-formed" for DOM blocks iff it has a real name (not the generic fallback) AND a numeric price. */ +function isWellFormedDom(p: Plan): boolean { + return p.name !== "Plan" && p.price.amount !== null; +} + export const pricingCollector: Collector = { type: "pricing", fetch: (source) => httpFetch(source.url), parse(raw: RawCapture): ParseResult { + // JSON-LD path: keep only Products whose offer has a numeric price. + // If JSON-LD Products were found but none have a price → 0.1 (priceless Product, e.g. Sentry-style). const jsonld = fromJsonLd(raw.raw); - if (jsonld.length > 0) return { structured: { plans: jsonld }, confidence: 0.9 }; + if (jsonld.length > 0) { + const priced = jsonld.filter((p) => p.price.amount !== null); + return { structured: { plans: priced }, confidence: priced.length > 0 ? 0.9 : 0.1 }; + } + + // DOM-heuristic path: keep only blocks with a real name AND a price. + // Implausibly many well-formed plans (> 8) = noise, not a real pricing page. const dom = fromDomHeuristic(raw.raw); - if (dom.length > 0) return { structured: { plans: dom }, confidence: 0.5 }; - return { structured: { plans: [] }, confidence: 0.1 }; + const wellFormed = dom.filter(isWellFormedDom); + const confidence = wellFormed.length === 0 || wellFormed.length > 8 ? 0.1 : 0.5; + return { structured: { plans: wellFormed }, confidence }; }, }; From 76c3205c6e78cc8d0ea3294396fd4d1bb5bac61f Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 12:48:28 +0530 Subject: [PATCH 22/55] feat(competitor): add normalizeHtml engine helper (Tier-1 detection floor) Co-Authored-By: Claude Opus 4.8 --- .../competitor/__tests__/normalize.test.ts | 38 ++++++++++++++++ packages/engine/src/competitor/index.ts | 1 + packages/engine/src/competitor/normalize.ts | 44 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 packages/engine/src/competitor/__tests__/normalize.test.ts create mode 100644 packages/engine/src/competitor/normalize.ts diff --git a/packages/engine/src/competitor/__tests__/normalize.test.ts b/packages/engine/src/competitor/__tests__/normalize.test.ts new file mode 100644 index 00000000..8e6d320a --- /dev/null +++ b/packages/engine/src/competitor/__tests__/normalize.test.ts @@ -0,0 +1,38 @@ +// packages/engine/src/competitor/__tests__/normalize.test.ts +import { describe, it, expect } from "vitest"; +import { normalizeHtml } from "../normalize"; + +describe("normalizeHtml", () => { + it("strips script/style/noscript/svg content", () => { + const html = `
Hi
`; + expect(normalizeHtml(html)).toBe("Hi"); + }); + + it("drops attributes so nonce/CSRF churn does not change output", () => { + const a = `

Plan $29

`; + const b = `

Plan $29

`; + expect(normalizeHtml(a)).toBe(normalizeHtml(b)); + expect(normalizeHtml(a)).toBe("Plan $29"); + }); + + it("turns block boundaries into separate lines", () => { + const html = `
  • Free
  • Pro

Enterprise

`; + expect(normalizeHtml(html)).toBe("Free\nPro\nEnterprise"); + }); + + it("collapses whitespace, decodes entities, drops empty lines", () => { + const html = `

A & B

\n\n

 

C's

`; + expect(normalizeHtml(html)).toBe("A & B\nC's"); + }); + + it("is idempotent-stable: comment-only difference yields identical text", () => { + const a = `

Same

`; + const b = `

Same

`; + expect(normalizeHtml(a)).toBe(normalizeHtml(b)); + }); + + it("returns empty string for tag-only / empty input", () => { + expect(normalizeHtml(`
`)).toBe(""); + expect(normalizeHtml("")).toBe(""); + }); +}); diff --git a/packages/engine/src/competitor/index.ts b/packages/engine/src/competitor/index.ts index c8b2a4c5..fb83adfd 100644 --- a/packages/engine/src/competitor/index.ts +++ b/packages/engine/src/competitor/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./diff"; export * from "./rules"; +export * from "./normalize"; diff --git a/packages/engine/src/competitor/normalize.ts b/packages/engine/src/competitor/normalize.ts new file mode 100644 index 00000000..baae22cc --- /dev/null +++ b/packages/engine/src/competitor/normalize.ts @@ -0,0 +1,44 @@ +// packages/engine/src/competitor/normalize.ts + +/** Block-level closing tags whose boundary becomes a newline in visible text. */ +const BLOCK_CLOSE = + /<\/(p|div|li|ul|ol|section|article|header|footer|nav|main|aside|table|tr|thead|tbody|h[1-6]|blockquote|pre|figure|figcaption|form|dd|dt)\s*>/gi; +const BR = //gi; + +function decodeEntities(s: string): string { + // Decode & LAST so we never double-decode (e.g. "&lt;" must stay "<"). + return s + .replace(/ /gi, " ") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/�*39;/g, "'") + .replace(/'/gi, "'") + .replace(/&/gi, "&"); +} + +/** + * HTML → stable visible text (one line per visible block). Deterministic and + * pure. Same visible content with different nonces / attributes / whitespace / + * comments / inline scripts normalizes to identical output — this is what lets + * the pipeline's store-on-change ignore raw-HTML churn (spec §3.1). + */ +export function normalizeHtml(raw: string): string { + let s = raw; + // 1. Remove elements whose contents are never visible text. + s = s.replace(/<(script|style|noscript|svg|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " "); + // 2. Remove HTML comments. + s = s.replace(//g, " "); + // 3. Block boundaries → newline (preserve line structure for the line-diff). + s = s.replace(BR, "\n").replace(BLOCK_CLOSE, "\n"); + // 4. Strip every remaining tag (removes all attributes → nonce/CSRF/data-* gone). + s = s.replace(/<[^>]+>/g, " "); + // 5. Decode common entities. + s = decodeEntities(s); + // 6. Per line: collapse intra-line whitespace, trim, drop empties. + return s + .split("\n") + .map((line) => line.replace(/[^\S\n]+/g, " ").trim()) + .filter((line) => line.length > 0) + .join("\n"); +} From 0f8db4732d78bac10699330f87d1fe247cc47962 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 12:53:35 +0530 Subject: [PATCH 23/55] feat(competitor): add diffText + contentChangeAlert (Tier-1 change output) Adds two pure engine functions to packages/engine/src/competitor/text-diff.ts: - diffText: multiset line-difference over normalized visible text (order-insensitive, O(n), lines capped at 20, truncated flag) - contentChangeAlert: converts a TextDiff into an info Alert or null Re-exported via competitor/index.ts. 6 tests added, all pass. Co-Authored-By: Claude Opus 4.8 --- .../competitor/__tests__/text-diff.test.ts | 49 ++++++++++++++ packages/engine/src/competitor/index.ts | 1 + packages/engine/src/competitor/text-diff.ts | 66 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/engine/src/competitor/__tests__/text-diff.test.ts create mode 100644 packages/engine/src/competitor/text-diff.ts diff --git a/packages/engine/src/competitor/__tests__/text-diff.test.ts b/packages/engine/src/competitor/__tests__/text-diff.test.ts new file mode 100644 index 00000000..c09dfc46 --- /dev/null +++ b/packages/engine/src/competitor/__tests__/text-diff.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { diffText, contentChangeAlert } from "../text-diff"; + +describe("diffText", () => { + it("reports added and removed lines by multiset difference", () => { + const d = diffText("Free\nPro $29\nEnterprise", "Free\nPro $39\nEnterprise\nTeam"); + expect(d.removedLines).toEqual(["Pro $29"]); + expect(d.addedLines).toEqual(["Pro $39", "Team"]); + expect(d.removedCount).toBe(1); + expect(d.addedCount).toBe(2); + expect(d.truncated).toBe(false); + }); + + it("treats identical content (any order) as no change", () => { + const d = diffText("A\nB\nC", "C\nB\nA"); + expect(d.addedCount).toBe(0); + expect(d.removedCount).toBe(0); + }); + + it("handles empty previous (first snapshot)", () => { + const d = diffText("", "Only"); + expect(d.addedLines).toEqual(["Only"]); + expect(d.removedCount).toBe(0); + }); + + it("caps displayed lines at 20 and flags truncation", () => { + const next = Array.from({ length: 25 }, (_, i) => `L${i}`).join("\n"); + const d = diffText("", next); + expect(d.addedCount).toBe(25); + expect(d.addedLines).toHaveLength(20); + expect(d.truncated).toBe(true); + }); +}); + +describe("contentChangeAlert", () => { + it("returns null when nothing changed", () => { + expect(contentChangeAlert(diffText("A", "A"), "Pricing page")).toBeNull(); + }); + + it("builds an info alert with counts and capped line snippets", () => { + const alert = contentChangeAlert(diffText("Pro 29", "Pro 39"), "Pricing page"); + expect(alert).not.toBeNull(); + expect(alert!.changeType).toBe("content_changed"); + expect(alert!.severity).toBe("info"); + expect(alert!.summary).toBe("Pricing page content changed: +1 / -1 lines"); + expect(alert!.before).toEqual({ lines: ["Pro 29"], truncated: false }); + expect(alert!.after).toEqual({ lines: ["Pro 39"], truncated: false }); + }); +}); diff --git a/packages/engine/src/competitor/index.ts b/packages/engine/src/competitor/index.ts index fb83adfd..47168326 100644 --- a/packages/engine/src/competitor/index.ts +++ b/packages/engine/src/competitor/index.ts @@ -2,3 +2,4 @@ export * from "./types"; export * from "./diff"; export * from "./rules"; export * from "./normalize"; +export * from "./text-diff"; diff --git a/packages/engine/src/competitor/text-diff.ts b/packages/engine/src/competitor/text-diff.ts new file mode 100644 index 00000000..677208a7 --- /dev/null +++ b/packages/engine/src/competitor/text-diff.ts @@ -0,0 +1,66 @@ +import type { Alert } from "./types"; + +export interface TextDiff { + addedLines: string[]; // capped to CAP + removedLines: string[]; // capped to CAP + addedCount: number; // uncapped + removedCount: number; // uncapped + truncated: boolean; +} + +const CAP = 20; + +function counts(lines: string[]): Map { + const m = new Map(); + for (const l of lines) m.set(l, (m.get(l) ?? 0) + 1); + return m; +} + +/** + * Multiset line difference over normalized visible text. Order-insensitive and + * O(n): a line moved but otherwise unchanged registers as no change. Returns + * what text appeared (added) / disappeared (removed) — the useful monitoring + * signal — with displayed lists capped at CAP (spec §3.2). + */ +export function diffText(prev: string, next: string): TextDiff { + const prevLines = prev ? prev.split("\n") : []; + const nextLines = next ? next.split("\n") : []; + const prevCounts = counts(prevLines); + const nextCounts = counts(nextLines); + + const added: string[] = []; + for (const [line, n] of nextCounts) { + const surplus = n - (prevCounts.get(line) ?? 0); + for (let i = 0; i < surplus; i++) added.push(line); + } + const removed: string[] = []; + for (const [line, p] of prevCounts) { + const surplus = p - (nextCounts.get(line) ?? 0); + for (let i = 0; i < surplus; i++) removed.push(line); + } + + return { + addedLines: added.slice(0, CAP), + removedLines: removed.slice(0, CAP), + addedCount: added.length, + removedCount: removed.length, + truncated: added.length > CAP || removed.length > CAP, + }; +} + +/** + * Tier-1 generic content-change alert. `label` is a human page label supplied + * by the caller (e.g. "Pricing page"). Returns null when the diff is empty so + * the pipeline emits no change. Kept as a dedicated helper (not folded into + * evaluateRules) because a content change is not a structured `Change`. + */ +export function contentChangeAlert(diff: TextDiff, label: string): Alert | null { + if (diff.addedCount === 0 && diff.removedCount === 0) return null; + return { + changeType: "content_changed", + summary: `${label} content changed: +${diff.addedCount} / -${diff.removedCount} lines`, + severity: "info", + before: { lines: diff.removedLines, truncated: diff.truncated }, + after: { lines: diff.addedLines, truncated: diff.truncated }, + }; +} From 447e70eb3390b94f7b024e4297821002a3ecfe17 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 12:57:40 +0530 Subject: [PATCH 24/55] fix(competitor): derive per-side truncated in contentChangeAlert before/after each derive truncated from their own line count vs full count, instead of the OR-combined diff.truncated which misreported the smaller side under asymmetric changes. Adds a gap-closing test. Co-Authored-By: Claude Opus 4.8 --- .../engine/src/competitor/__tests__/text-diff.test.ts | 8 ++++++++ packages/engine/src/competitor/text-diff.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/competitor/__tests__/text-diff.test.ts b/packages/engine/src/competitor/__tests__/text-diff.test.ts index c09dfc46..841c81c4 100644 --- a/packages/engine/src/competitor/__tests__/text-diff.test.ts +++ b/packages/engine/src/competitor/__tests__/text-diff.test.ts @@ -46,4 +46,12 @@ describe("contentChangeAlert", () => { expect(alert!.before).toEqual({ lines: ["Pro 29"], truncated: false }); expect(alert!.after).toEqual({ lines: ["Pro 39"], truncated: false }); }); + + it("flags truncation per side under asymmetric change sizes", () => { + const d = diffText(Array.from({ length: 25 }, (_, i) => `R${i}`).join("\n"), "One"); + const a = contentChangeAlert(d, "X"); + expect(a).not.toBeNull(); + expect((a!.before as { truncated: boolean }).truncated).toBe(true); + expect((a!.after as { truncated: boolean }).truncated).toBe(false); + }); }); diff --git a/packages/engine/src/competitor/text-diff.ts b/packages/engine/src/competitor/text-diff.ts index 677208a7..ac816ffb 100644 --- a/packages/engine/src/competitor/text-diff.ts +++ b/packages/engine/src/competitor/text-diff.ts @@ -60,7 +60,7 @@ export function contentChangeAlert(diff: TextDiff, label: string): Alert | null changeType: "content_changed", summary: `${label} content changed: +${diff.addedCount} / -${diff.removedCount} lines`, severity: "info", - before: { lines: diff.removedLines, truncated: diff.truncated }, - after: { lines: diff.addedLines, truncated: diff.truncated }, + before: { lines: diff.removedLines, truncated: diff.removedLines.length < diff.removedCount }, + after: { lines: diff.addedLines, truncated: diff.addedLines.length < diff.addedCount }, }; } From f47cbcb3e7c497435971b9c3aa459e0f52e452c4 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:01:13 +0530 Subject: [PATCH 25/55] feat(competitor): add normalized text + hash columns to snapshots (additive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two nullable columns to competitor_snapshots: `normalized` (visible text after HTML stripping) and `normalized_hash` (for store-on-change keying in Task 4 pipeline). Migration is purely additive — two ALTER TABLE ADD COLUMN statements with no NOT NULL/default; existing rows keep NULL and re-baseline on next run. Extends insertSnapshot input with optional normalized/normalizedHash fields; getLatestSnapshot surfaces them automatically via select *. TDD: round-trip test written first (RED), then schema+migration brought it GREEN alongside all 7 existing tests. Co-Authored-By: Claude Opus 4.8 --- packages/db/drizzle/0016_sad_moondragon.sql | 2 + packages/db/drizzle/meta/0016_snapshot.json | 8828 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/__tests__/competitor.test.ts | 22 + packages/db/src/queries/competitor.ts | 2 + packages/db/src/schema/competitor.ts | 5 + 6 files changed, 8866 insertions(+) create mode 100644 packages/db/drizzle/0016_sad_moondragon.sql create mode 100644 packages/db/drizzle/meta/0016_snapshot.json diff --git a/packages/db/drizzle/0016_sad_moondragon.sql b/packages/db/drizzle/0016_sad_moondragon.sql new file mode 100644 index 00000000..916afc69 --- /dev/null +++ b/packages/db/drizzle/0016_sad_moondragon.sql @@ -0,0 +1,2 @@ +ALTER TABLE "competitor_snapshots" ADD COLUMN "normalized" text;--> statement-breakpoint +ALTER TABLE "competitor_snapshots" ADD COLUMN "normalized_hash" text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..c1fc2b26 --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,8828 @@ +{ + "id": "3bc3f20d-47d1-4707-8b06-2b9d56e11bce", + "prevId": "b5af6d6c-18e5-41c8-a653-6af8fa6c358a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_provider_account_id_pk": { + "name": "accounts_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_backup_codes": { + "name": "two_factor_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_four": { + "name": "last_four", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_tokens_hash_idx": { + "name": "api_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_user_company_idx": { + "name": "api_tokens_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_company_idx": { + "name": "api_tokens_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_tokens_company_id_companies_id_fk": { + "name": "api_tokens_company_id_companies_id_fk", + "tableFrom": "api_tokens", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "company_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pre_seed'" + }, + "business_model": { + "name": "business_model", + "type": "business_model", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'saas'" + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "founded_date": { + "name": "founded_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "fiscal_year_end": { + "name": "fiscal_year_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en-US'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/New_York'" + }, + "region": { + "name": "region", + "type": "data_region", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'us-east'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_provider": { + "name": "billing_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_subscription_id": { + "name": "billing_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_plan": { + "name": "billing_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'free'" + }, + "benefits_rates": { + "name": "benefits_rates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "founders_ownership_percent": { + "name": "founders_ownership_percent", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": true, + "default": "'100.0000'" + }, + "mcp_server_enabled": { + "name": "mcp_server_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "companies_owner_id_users_id_fk": { + "name": "companies_owner_id_users_id_fk", + "tableFrom": "companies", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_members": { + "name": "company_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_member_unique": { + "name": "company_member_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_member_user_idx": { + "name": "company_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_members_company_id_companies_id_fk": { + "name": "company_members_company_id_companies_id_fk", + "tableFrom": "company_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_members_user_id_users_id_fk": { + "name": "company_members_user_id_users_id_fk", + "tableFrom": "company_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_company_idx": { + "name": "departments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_company_id_companies_id_fk": { + "name": "departments_company_id_companies_id_fk", + "tableFrom": "departments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_code_redemptions": { + "name": "invite_code_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_code_id": { + "name": "invite_code_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_redemptions_code_idx": { + "name": "invite_redemptions_code_idx", + "columns": [ + { + "expression": "invite_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_redemptions_user_code_idx": { + "name": "invite_redemptions_user_code_idx", + "columns": [ + { + "expression": "invite_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_code_redemptions_invite_code_id_invite_codes_id_fk": { + "name": "invite_code_redemptions_invite_code_id_invite_codes_id_fk", + "tableFrom": "invite_code_redemptions", + "tableTo": "invite_codes", + "columnsFrom": [ + "invite_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_code_redemptions_user_id_users_id_fk": { + "name": "invite_code_redemptions_user_id_users_id_fk", + "tableFrom": "invite_code_redemptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_codes": { + "name": "invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "invite_code_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'single_use'" + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_redemptions": { + "name": "current_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "free_platform_days": { + "name": "free_platform_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "ai_credits_cents": { + "name": "ai_credits_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_codes_code_idx": { + "name": "invite_codes_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_created_by_idx": { + "name": "invite_codes_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_active_idx": { + "name": "invite_codes_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_codes_created_by_users_id_fk": { + "name": "invite_codes_created_by_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_codes": { + "name": "oauth_auth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_auth_codes_hash_idx": { + "name": "oauth_auth_codes_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_codes_client_id_oauth_clients_id_fk": { + "name": "oauth_auth_codes_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_auth_codes_user_id_users_id_fk": { + "name": "oauth_auth_codes_user_id_users_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_auth_codes_company_id_companies_id_fk": { + "name": "oauth_auth_codes_company_id_companies_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_expires_at": { + "name": "access_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_tokens_access_hash_idx": { + "name": "oauth_tokens_access_hash_idx", + "columns": [ + { + "expression": "access_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_refresh_hash_idx": { + "name": "oauth_tokens_refresh_hash_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_grant_idx": { + "name": "oauth_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_user_company_idx": { + "name": "oauth_tokens_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_tokens_client_id_oauth_clients_id_fk": { + "name": "oauth_tokens_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_company_id_companies_id_fk": { + "name": "oauth_tokens_company_id_companies_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_credentials": { + "name": "integration_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "integration_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_credentials_company_type_idx": { + "name": "integration_credentials_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_credentials_company_id_companies_id_fk": { + "name": "integration_credentials_company_id_companies_id_fk", + "tableFrom": "integration_credentials", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integration_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integration_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integrations_company_type_idx": { + "name": "integrations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrations_company_id_companies_id_fk": { + "name": "integrations_company_id_companies_id_fk", + "tableFrom": "integrations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "mcp_owner_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "mcp_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "mcp_auth_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "status": { + "name": "status", + "type": "mcp_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_company_idx": { + "name": "mcp_connections_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_owner_idx": { + "name": "mcp_connections_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_company_name_idx": { + "name": "mcp_connections_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_company_slug_idx": { + "name": "mcp_connections_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_company_id_companies_id_fk": { + "name": "mcp_connections_company_id_companies_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_connections_owner_user_id_users_id_fk": { + "name": "mcp_connections_owner_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_connections_personal_owner_check": { + "name": "mcp_connections_personal_owner_check", + "value": "(owner_scope = 'personal') = (owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mcp_credentials": { + "name": "mcp_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "mcp_auth_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_registration": { + "name": "client_registration", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_credentials_connection_idx": { + "name": "mcp_credentials_connection_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_credentials_mcp_connection_id_mcp_connections_id_fk": { + "name": "mcp_credentials_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_credentials", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_prefs": { + "name": "mcp_tool_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "perm_class_override": { + "name": "perm_class_override", + "type": "mcp_tool_perm", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_tool_prefs_connection_tool_idx": { + "name": "mcp_tool_prefs_connection_tool_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_prefs_mcp_connection_id_mcp_connections_id_fk": { + "name": "mcp_tool_prefs_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_tool_prefs", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_preferences": { + "name": "dashboard_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "dashboard_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dynamic'" + }, + "hero_cards": { + "name": "hero_cards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "secondary_metrics": { + "name": "secondary_metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "card_mode_overrides": { + "name": "card_mode_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "card_scenario_overrides": { + "name": "card_scenario_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "custom_slug_overrides": { + "name": "custom_slug_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "slot_overrides": { + "name": "slot_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_metrics": { + "name": "custom_metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "closed_widgets": { + "name": "closed_widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "page_layouts": { + "name": "page_layouts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboard_prefs_user_company_idx": { + "name": "dashboard_prefs_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_preferences_user_id_users_id_fk": { + "name": "dashboard_preferences_user_id_users_id_fk", + "tableFrom": "dashboard_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_preferences_company_id_companies_id_fk": { + "name": "dashboard_preferences_company_id_companies_id_fk", + "tableFrom": "dashboard_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_logs": { + "name": "export_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "export_type": { + "name": "export_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "export_logs_company_idx": { + "name": "export_logs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "export_logs_company_created_idx": { + "name": "export_logs_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "export_logs_user_idx": { + "name": "export_logs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "export_logs_company_id_companies_id_fk": { + "name": "export_logs_company_id_companies_id_fk", + "tableFrom": "export_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "export_logs_user_id_users_id_fk": { + "name": "export_logs_user_id_users_id_fk", + "tableFrom": "export_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "notification_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_unread_idx": { + "name": "notifications_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_company_id_companies_id_fk": { + "name": "notifications_company_id_companies_id_fk", + "tableFrom": "notifications", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privacy_consents": { + "name": "privacy_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "consent_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "granted": { + "name": "granted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "privacy_consents_user_idx": { + "name": "privacy_consents_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "privacy_consents_user_purpose_idx": { + "name": "privacy_consents_user_purpose_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "privacy_consents_user_id_users_id_fk": { + "name": "privacy_consents_user_id_users_id_fk", + "tableFrom": "privacy_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_job_runs": { + "name": "scheduled_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scheduled_job_id": { + "name": "scheduled_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "scheduled_job_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "scheduled_job_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scheduled_job_runs_job_idx": { + "name": "scheduled_job_runs_job_idx", + "columns": [ + { + "expression": "scheduled_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_job_runs_company_idx": { + "name": "scheduled_job_runs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_job_runs_scheduled_job_id_scheduled_jobs_id_fk": { + "name": "scheduled_job_runs_scheduled_job_id_scheduled_jobs_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "scheduled_jobs", + "columnsFrom": [ + "scheduled_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_job_runs_company_id_companies_id_fk": { + "name": "scheduled_job_runs_company_id_companies_id_fk", + "tableFrom": "scheduled_job_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_jobs": { + "name": "scheduled_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_kind": { + "name": "action_kind", + "type": "scheduled_job_action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "bound_connection_ids": { + "name": "bound_connection_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "scheduled_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notify_policy": { + "name": "notify_policy", + "type": "scheduled_job_notify_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'smart'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_cursor": { + "name": "last_run_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scheduled_jobs_company_idx": { + "name": "scheduled_jobs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_jobs_due_idx": { + "name": "scheduled_jobs_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_jobs_company_id_companies_id_fk": { + "name": "scheduled_jobs_company_id_companies_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_jobs_created_by_user_id_users_id_fk": { + "name": "scheduled_jobs_created_by_user_id_users_id_fk", + "tableFrom": "scheduled_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidebar_order": { + "name": "sidebar_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quick_action_mode": { + "name": "quick_action_mode", + "type": "quick_action_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dynamic'" + }, + "quick_action_mode_overrides": { + "name": "quick_action_mode_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_quick_actions": { + "name": "custom_quick_actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sidebar_collapsed": { + "name": "sidebar_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled_mcp_connections": { + "name": "disabled_mcp_connections", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_builtin_tools": { + "name": "disabled_builtin_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_preferences_user_company_idx": { + "name": "user_preferences_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_company_id_companies_id_fk": { + "name": "user_preferences_company_id_companies_id_fk", + "tableFrom": "user_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weekly_digests": { + "name": "weekly_digests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "narrative": { + "name": "narrative", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deterministic_summary": { + "name": "deterministic_summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_sent_at": { + "name": "email_sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "weekly_digests_company_idx": { + "name": "weekly_digests_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "weekly_digests_company_week_idx": { + "name": "weekly_digests_company_week_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weekly_digests_company_id_companies_id_fk": { + "name": "weekly_digests_company_id_companies_id_fk", + "tableFrom": "weekly_digests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_conversations": { + "name": "ai_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_grants": { + "name": "session_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "session_disabled_tools": { + "name": "session_disabled_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_conversations_company_idx": { + "name": "ai_conversations_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_conversations_company_user_idx": { + "name": "ai_conversations_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversations_company_id_companies_id_fk": { + "name": "ai_conversations_company_id_companies_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_feature_flags": { + "name": "ai_feature_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "master_enabled": { + "name": "master_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "data_mode": { + "name": "data_mode", + "type": "ai_data_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "monthly_budget_cents": { + "name": "monthly_budget_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"onboarding\":true,\"chat\":true,\"insights\":true,\"uiPersonalization\":true,\"autoCategorization\":true,\"weeklyDigest\":true}'::jsonb" + }, + "write_mode": { + "name": "write_mode", + "type": "ai_write_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'confirm'" + }, + "companion_name": { + "name": "companion_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Companion'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_feature_flags_company_idx": { + "name": "ai_feature_flags_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_feature_flags_company_id_companies_id_fk": { + "name": "ai_feature_flags_company_id_companies_id_fk", + "tableFrom": "ai_feature_flags", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_insight_cache": { + "name": "ai_insight_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ai_insight_cache_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stale_reason": { + "name": "stale_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_insight_cache_company_idx": { + "name": "ai_insight_cache_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_insight_cache_company_key_idx": { + "name": "ai_insight_cache_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_insight_cache_company_id_companies_id_fk": { + "name": "ai_insight_cache_company_id_companies_id_fk", + "tableFrom": "ai_insight_cache", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_permission_defaults": { + "name": "ai_permission_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_mode": { + "name": "read_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "write_mode": { + "name": "write_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "delete_mode": { + "name": "delete_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "web_search_mode": { + "name": "web_search_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "browser_use_mode": { + "name": "browser_use_mode", + "type": "ai_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_permission_defaults_user_company_idx": { + "name": "ai_permission_defaults_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_permission_defaults_user_id_users_id_fk": { + "name": "ai_permission_defaults_user_id_users_id_fk", + "tableFrom": "ai_permission_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_permission_defaults_company_id_companies_id_fk": { + "name": "ai_permission_defaults_company_id_companies_id_fk", + "tableFrom": "ai_permission_defaults", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_provider_models": { + "name": "ai_provider_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supports_tools": { + "name": "supports_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "supports_images": { + "name": "supports_images", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ai_provider_model_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_provider_models_provider_idx": { + "name": "ai_provider_models_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_provider_models_provider_model_idx": { + "name": "ai_provider_models_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_provider_models_provider_id_ai_providers_id_fk": { + "name": "ai_provider_models_provider_id_ai_providers_id_fk", + "tableFrom": "ai_provider_models", + "tableTo": "ai_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_providers": { + "name": "ai_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "ai_provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_mode": { + "name": "api_key_mode", + "type": "ai_api_key_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user_provided'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drop_params": { + "name": "drop_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_providers_company_idx": { + "name": "ai_providers_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_providers_company_id_companies_id_fk": { + "name": "ai_providers_company_id_companies_id_fk", + "tableFrom": "ai_providers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_tool_audit_logs": { + "name": "ai_tool_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_connection_id": { + "name": "mcp_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_job_run_id": { + "name": "scheduled_job_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ai_tool_audit_log_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permission_decision": { + "name": "permission_decision", + "type": "ai_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_info": { + "name": "client_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_tool_audit_company_idx": { + "name": "ai_tool_audit_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_user_idx": { + "name": "ai_tool_audit_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_created_idx": { + "name": "ai_tool_audit_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_tool_idx": { + "name": "ai_tool_audit_tool_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_conversation_idx": { + "name": "ai_tool_audit_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_mcp_connection_idx": { + "name": "ai_tool_audit_mcp_connection_idx", + "columns": [ + { + "expression": "mcp_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tool_audit_scheduled_job_run_idx": { + "name": "ai_tool_audit_scheduled_job_run_idx", + "columns": [ + { + "expression": "scheduled_job_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_tool_audit_logs_company_id_companies_id_fk": { + "name": "ai_tool_audit_logs_company_id_companies_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_user_id_users_id_fk": { + "name": "ai_tool_audit_logs_user_id_users_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_conversation_id_ai_conversations_id_fk": { + "name": "ai_tool_audit_logs_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_mcp_connection_id_mcp_connections_id_fk": { + "name": "ai_tool_audit_logs_mcp_connection_id_mcp_connections_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "mcp_connections", + "columnsFrom": [ + "mcp_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_tool_audit_logs_scheduled_job_run_id_scheduled_job_runs_id_fk": { + "name": "ai_tool_audit_logs_scheduled_job_run_id_scheduled_job_runs_id_fk", + "tableFrom": "ai_tool_audit_logs", + "tableTo": "scheduled_job_runs", + "columnsFrom": [ + "scheduled_job_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_turn_events": { + "name": "ai_turn_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "ai_turn_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_turn_events_conversation_seq_idx": { + "name": "ai_turn_events_conversation_seq_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_turn_events_open_gate_idx": { + "name": "ai_turn_events_open_gate_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_turn_events\".\"type\" = 'gate' AND \"ai_turn_events\".\"resolved_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_turn_events_conversation_id_ai_conversations_id_fk": { + "name": "ai_turn_events_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_turn_events", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage_logs": { + "name": "ai_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_company_idx": { + "name": "ai_usage_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_feature_idx": { + "name": "ai_usage_feature_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_created_idx": { + "name": "ai_usage_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_logs_company_id_companies_id_fk": { + "name": "ai_usage_logs_company_id_companies_id_fk", + "tableFrom": "ai_usage_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insight_invalidations": { + "name": "insight_invalidations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "insight_type": { + "name": "insight_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mutation_source": { + "name": "mutation_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_invalidated_at": { + "name": "first_invalidated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_mutation_at": { + "name": "last_mutation_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "insight_invalidations_company_type_idx": { + "name": "insight_invalidations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "insight_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insight_invalidations_pending_idx": { + "name": "insight_invalidations_pending_idx", + "columns": [ + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insight_invalidations_company_id_companies_id_fk": { + "name": "insight_invalidations_company_id_companies_id_fk", + "tableFrom": "insight_invalidations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_only": { + "name": "read_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_company_idx": { + "name": "memory_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_company_domain_kind_idx": { + "name": "memory_company_domain_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_company_tier_idx": { + "name": "memory_company_tier_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_embedding_hnsw": { + "name": "memory_embedding_hnsw", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "where": "\"memory\".\"embedding\" IS NOT NULL", + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "memory_company_id_companies_id_fk": { + "name": "memory_company_id_companies_id_fk", + "tableFrom": "memory", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_user_id_users_id_fk": { + "name": "memory_user_id_users_id_fk", + "tableFrom": "memory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bonuses": { + "name": "bonuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout_month": { + "name": "payout_month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "bonus_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'performance'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bonuses_company_idx": { + "name": "bonuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bonuses_headcount_month_idx": { + "name": "bonuses_headcount_month_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payout_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bonuses_company_id_companies_id_fk": { + "name": "bonuses_company_id_companies_id_fk", + "tableFrom": "bonuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bonuses_headcount_id_headcount_plans_id_fk": { + "name": "bonuses_headcount_id_headcount_plans_id_fk", + "tableFrom": "bonuses", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.equity_grants": { + "name": "equity_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_date": { + "name": "grant_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": true + }, + "strike_price": { + "name": "strike_price", + "type": "numeric(18, 4)", + "primaryKey": false, + "notNull": false + }, + "grant_type": { + "name": "grant_type", + "type": "equity_grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'iso'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "equity_grants_company_idx": { + "name": "equity_grants_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "equity_grants_headcount_idx": { + "name": "equity_grants_headcount_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "equity_grants_company_id_companies_id_fk": { + "name": "equity_grants_company_id_companies_id_fk", + "tableFrom": "equity_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "equity_grants_headcount_id_headcount_plans_id_fk": { + "name": "equity_grants_headcount_id_headcount_plans_id_fk", + "tableFrom": "equity_grants", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_accounts": { + "name": "financial_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "account_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "covers_headcount": { + "name": "covers_headcount", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_accounts_company_idx": { + "name": "financial_accounts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_accounts_parent_idx": { + "name": "financial_accounts_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "financial_accounts_company_id_companies_id_fk": { + "name": "financial_accounts_company_id_companies_id_fk", + "tableFrom": "financial_accounts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_audit_logs": { + "name": "financial_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "audit_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_audit_company_idx": { + "name": "financial_audit_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_entity_idx": { + "name": "financial_audit_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_user_idx": { + "name": "financial_audit_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "financial_audit_created_idx": { + "name": "financial_audit_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "financial_audit_logs_company_id_companies_id_fk": { + "name": "financial_audit_logs_company_id_companies_id_fk", + "tableFrom": "financial_audit_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "financial_audit_logs_user_id_users_id_fk": { + "name": "financial_audit_logs_user_id_users_id_fk", + "tableFrom": "financial_audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forecast_lines": { + "name": "forecast_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "forecast_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "expense_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + }, + "is_one_time": { + "name": "is_one_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forecast_lines_company_idx": { + "name": "forecast_lines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_account_idx": { + "name": "forecast_lines_company_account_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_department_idx": { + "name": "forecast_lines_company_department_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_vendor_idx": { + "name": "forecast_lines_vendor_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_lines_company_name_idx": { + "name": "forecast_lines_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"forecast_lines\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forecast_lines_company_id_companies_id_fk": { + "name": "forecast_lines_company_id_companies_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forecast_lines_account_id_financial_accounts_id_fk": { + "name": "forecast_lines_account_id_financial_accounts_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forecast_lines_department_id_departments_id_fk": { + "name": "forecast_lines_department_id_departments_id_fk", + "tableFrom": "forecast_lines", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forecast_values": { + "name": "forecast_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "forecast_line_id": { + "name": "forecast_line_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "is_override": { + "name": "is_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forecast_values_line_idx": { + "name": "forecast_values_line_idx", + "columns": [ + { + "expression": "forecast_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_values_month_idx": { + "name": "forecast_values_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forecast_values_line_month_idx": { + "name": "forecast_values_line_month_idx", + "columns": [ + { + "expression": "forecast_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forecast_values_forecast_line_id_forecast_lines_id_fk": { + "name": "forecast_values_forecast_line_id_forecast_lines_id_fk", + "tableFrom": "forecast_values", + "tableTo": "forecast_lines", + "columnsFrom": [ + "forecast_line_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_round_investors": { + "name": "funding_round_investors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "funding_round_id": { + "name": "funding_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_invested": { + "name": "amount_invested", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_round_investors_round_idx": { + "name": "funding_round_investors_round_idx", + "columns": [ + { + "expression": "funding_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_round_investors_funding_round_id_funding_rounds_id_fk": { + "name": "funding_round_investors_funding_round_id_funding_rounds_id_fk", + "tableFrom": "funding_round_investors", + "tableTo": "funding_rounds", + "columnsFrom": [ + "funding_round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_rounds": { + "name": "funding_rounds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "funding_round_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "pre_money_valuation": { + "name": "pre_money_valuation", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": false + }, + "dilution_percent": { + "name": "dilution_percent", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": false + }, + "is_projected": { + "name": "is_projected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "close_date": { + "name": "close_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_rounds_company_idx": { + "name": "funding_rounds_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_rounds_company_id_companies_id_fk": { + "name": "funding_rounds_company_id_companies_id_fk", + "tableFrom": "funding_rounds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.headcount_plans": { + "name": "headcount_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employee_type": { + "name": "employee_type", + "type": "headcount_employee_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full_time'" + }, + "count": { + "name": "count", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1.00'" + }, + "salary": { + "name": "salary", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "hourly_rate": { + "name": "hourly_rate", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "hours_per_week": { + "name": "hours_per_week", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "benefits_rate": { + "name": "benefits_rate", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0.20'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "headcount_plans_company_idx": { + "name": "headcount_plans_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "headcount_plans_department_idx": { + "name": "headcount_plans_department_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "headcount_plans_company_id_companies_id_fk": { + "name": "headcount_plans_company_id_companies_id_fk", + "tableFrom": "headcount_plans", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "headcount_plans_department_id_departments_id_fk": { + "name": "headcount_plans_department_id_departments_id_fk", + "tableFrom": "headcount_plans", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_batches": { + "name": "import_batches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_batch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_rows": { + "name": "total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "column_mapping": { + "name": "column_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "import_batches_company_idx": { + "name": "import_batches_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "import_batches_account_idx": { + "name": "import_batches_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_batches_company_id_companies_id_fk": { + "name": "import_batches_company_id_companies_id_fk", + "tableFrom": "import_batches", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "import_batches_account_id_financial_accounts_id_fk": { + "name": "import_batches_account_id_financial_accounts_id_fk", + "tableFrom": "import_batches", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merchant_category_mappings": { + "name": "merchant_category_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merchant_pattern": { + "name": "merchant_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "account_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user_override'" + }, + "override_count": { + "name": "override_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchant_mappings_company_idx": { + "name": "merchant_mappings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_pattern_idx": { + "name": "merchant_mappings_pattern_idx", + "columns": [ + { + "expression": "merchant_pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_account_idx": { + "name": "merchant_mappings_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merchant_mappings_company_pattern_idx": { + "name": "merchant_mappings_company_pattern_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "merchant_pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchant_category_mappings_company_id_companies_id_fk": { + "name": "merchant_category_mappings_company_id_companies_id_fk", + "tableFrom": "merchant_category_mappings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "merchant_category_mappings_account_id_financial_accounts_id_fk": { + "name": "merchant_category_mappings_account_id_financial_accounts_id_fk", + "tableFrom": "merchant_category_mappings", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metrics": { + "name": "metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formula": { + "name": "formula", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category": { + "name": "category", + "type": "metric_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'financial'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "metrics_company_slug_idx": { + "name": "metrics_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metrics_company_id_companies_id_fk": { + "name": "metrics_company_id_companies_id_fk", + "tableFrom": "metrics", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.option_pools": { + "name": "option_pools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_reserved": { + "name": "total_reserved", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true + }, + "refresh_date": { + "name": "refresh_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "option_pools_company_idx": { + "name": "option_pools_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "option_pools_company_id_companies_id_fk": { + "name": "option_pools_company_id_companies_id_fk", + "tableFrom": "option_pools", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revenue_streams": { + "name": "revenue_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "revenue_stream_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'subscription'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "revenue_streams_company_idx": { + "name": "revenue_streams_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "revenue_streams_active_idx": { + "name": "revenue_streams_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "end_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "revenue_streams_company_id_companies_id_fk": { + "name": "revenue_streams_company_id_companies_id_fk", + "tableFrom": "revenue_streams", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.salary_changes": { + "name": "salary_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headcount_id": { + "name": "headcount_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "new_salary": { + "name": "new_salary", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "salary_changes_company_idx": { + "name": "salary_changes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "salary_changes_headcount_date_idx": { + "name": "salary_changes_headcount_date_idx", + "columns": [ + { + "expression": "headcount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "salary_changes_company_id_companies_id_fk": { + "name": "salary_changes_company_id_companies_id_fk", + "tableFrom": "salary_changes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "salary_changes_headcount_id_headcount_plans_id_fk": { + "name": "salary_changes_headcount_id_headcount_plans_id_fk", + "tableFrom": "salary_changes", + "tableTo": "headcount_plans", + "columnsFrom": [ + "headcount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenario_overrides": { + "name": "scenario_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "scenario_override_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "original_data": { + "name": "original_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scenario_overrides_unique": { + "name": "scenario_overrides_unique", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scenario_overrides_scenario_type": { + "name": "scenario_overrides_scenario_type", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scenario_overrides_scenario_id_scenarios_id_fk": { + "name": "scenario_overrides_scenario_id_scenarios_id_fk", + "tableFrom": "scenario_overrides", + "tableTo": "scenarios", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenarios": { + "name": "scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "scenario_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'blank'" + }, + "status": { + "name": "status", + "type": "scenario_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_scenario_id": { + "name": "source_scenario_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_conversation_id": { + "name": "ai_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_delete_at": { + "name": "auto_delete_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scenarios_company_idx": { + "name": "scenarios_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scenarios_company_id_companies_id_fk": { + "name": "scenarios_company_id_companies_id_fk", + "tableFrom": "scenarios", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_classes": { + "name": "share_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "class_type": { + "name": "class_type", + "type": "share_class_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "total_authorized": { + "name": "total_authorized", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true + }, + "total_issued": { + "name": "total_issued", + "type": "numeric(18, 0)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "par_value": { + "name": "par_value", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0.000001'" + }, + "liquidation_preference": { + "name": "liquidation_preference", + "type": "numeric(7, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "share_classes_company_idx": { + "name": "share_classes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_classes_company_id_companies_id_fk": { + "name": "share_classes_company_id_companies_id_fk", + "tableFrom": "share_classes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "transaction_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_batch_id": { + "name": "import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transactions_company_date_idx": { + "name": "transactions_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_account_idx": { + "name": "transactions_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_external_id_idx": { + "name": "transactions_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_batch_idx": { + "name": "transactions_batch_idx", + "columns": [ + { + "expression": "import_batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_company_id_companies_id_fk": { + "name": "transactions_company_id_companies_id_fk", + "tableFrom": "transactions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_account_id_financial_accounts_id_fk": { + "name": "transactions_account_id_financial_accounts_id_fk", + "tableFrom": "transactions", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_changes": { + "name": "competitor_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_changes_company_idx": { + "name": "competitor_changes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_changes_competitor_idx": { + "name": "competitor_changes_competitor_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_changes_competitor_id_competitors_id_fk": { + "name": "competitor_changes_competitor_id_competitors_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_source_id_competitor_sources_id_fk": { + "name": "competitor_changes_source_id_competitor_sources_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitor_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_snapshot_id_competitor_snapshots_id_fk": { + "name": "competitor_changes_snapshot_id_competitor_snapshots_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "competitor_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_changes_company_id_companies_id_fk": { + "name": "competitor_changes_company_id_companies_id_fk", + "tableFrom": "competitor_changes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_snapshots": { + "name": "competitor_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_hash": { + "name": "raw_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "structured": { + "name": "structured", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "structured_hash": { + "name": "structured_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized": { + "name": "normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_hash": { + "name": "normalized_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_snapshots_source_idx": { + "name": "competitor_snapshots_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_snapshots_competitor_id_competitors_id_fk": { + "name": "competitor_snapshots_competitor_id_competitors_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_snapshots_source_id_competitor_sources_id_fk": { + "name": "competitor_snapshots_source_id_competitor_sources_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "competitor_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_snapshots_company_id_companies_id_fk": { + "name": "competitor_snapshots_company_id_companies_id_fk", + "tableFrom": "competitor_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_sources": { + "name": "competitor_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_hours": { + "name": "interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 168 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_state": { + "name": "health_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ok'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_sources_company_idx": { + "name": "competitor_sources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_sources_competitor_idx": { + "name": "competitor_sources_competitor_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_sources_competitor_id_competitors_id_fk": { + "name": "competitor_sources_competitor_id_competitors_id_fk", + "tableFrom": "competitor_sources", + "tableTo": "competitors", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_sources_company_id_companies_id_fk": { + "name": "competitor_sources_company_id_companies_id_fk", + "tableFrom": "competitor_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitors": { + "name": "competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitors_company_idx": { + "name": "competitors_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitors_company_id_companies_id_fk": { + "name": "competitors_company_id_companies_id_fk", + "tableFrom": "competitors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.business_model": { + "name": "business_model", + "schema": "public", + "values": [ + "saas", + "marketplace", + "ecommerce", + "services", + "hardware", + "other" + ] + }, + "public.company_stage": { + "name": "company_stage", + "schema": "public", + "values": [ + "pre_seed", + "seed", + "series_a", + "series_b", + "series_c_plus", + "bootstrapped" + ] + }, + "public.data_region": { + "name": "data_region", + "schema": "public", + "values": [ + "us-east", + "eu-west", + "ap-south" + ] + }, + "public.invite_code_type": { + "name": "invite_code_type", + "schema": "public", + "values": [ + "single_use", + "multi_use" + ] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "editor", + "viewer" + ] + }, + "public.integration_status": { + "name": "integration_status", + "schema": "public", + "values": [ + "active", + "disconnected", + "error" + ] + }, + "public.integration_type": { + "name": "integration_type", + "schema": "public", + "values": [ + "quickbooks", + "xero", + "freshbooks", + "plaid", + "mercury", + "gusto", + "stripe" + ] + }, + "public.mcp_auth_type": { + "name": "mcp_auth_type", + "schema": "public", + "values": [ + "oauth", + "pat", + "none" + ] + }, + "public.mcp_connection_status": { + "name": "mcp_connection_status", + "schema": "public", + "values": [ + "pending", + "connected", + "needs_auth", + "error", + "disabled" + ] + }, + "public.mcp_owner_scope": { + "name": "mcp_owner_scope", + "schema": "public", + "values": [ + "company", + "personal" + ] + }, + "public.mcp_tool_perm": { + "name": "mcp_tool_perm", + "schema": "public", + "values": [ + "read", + "write", + "delete" + ] + }, + "public.mcp_transport": { + "name": "mcp_transport", + "schema": "public", + "values": [ + "streamable_http", + "stdio" + ] + }, + "public.consent_purpose": { + "name": "consent_purpose", + "schema": "public", + "values": [ + "data_processing", + "ai_features", + "marketing", + "analytics" + ] + }, + "public.dashboard_mode": { + "name": "dashboard_mode", + "schema": "public", + "values": [ + "intelligence", + "dynamic", + "custom" + ] + }, + "public.notification_severity": { + "name": "notification_severity", + "schema": "public", + "values": [ + "info", + "success", + "warning", + "error" + ] + }, + "public.quick_action_mode": { + "name": "quick_action_mode", + "schema": "public", + "values": [ + "intelligence", + "dynamic", + "custom" + ] + }, + "public.scheduled_job_action_kind": { + "name": "scheduled_job_action_kind", + "schema": "public", + "values": [ + "write", + "notify" + ] + }, + "public.scheduled_job_notify_policy": { + "name": "scheduled_job_notify_policy", + "schema": "public", + "values": [ + "smart", + "failures", + "every", + "off" + ] + }, + "public.scheduled_job_run_status": { + "name": "scheduled_job_run_status", + "schema": "public", + "values": [ + "running", + "success", + "failed", + "missed" + ] + }, + "public.scheduled_job_run_trigger": { + "name": "scheduled_job_run_trigger", + "schema": "public", + "values": [ + "schedule", + "manual", + "dry_run" + ] + }, + "public.scheduled_job_status": { + "name": "scheduled_job_status", + "schema": "public", + "values": [ + "active", + "disabled", + "auto_disabled", + "error" + ] + }, + "public.ai_api_key_mode": { + "name": "ai_api_key_mode", + "schema": "public", + "values": [ + "managed", + "user_provided", + "none" + ] + }, + "public.ai_data_mode": { + "name": "ai_data_mode", + "schema": "public", + "values": [ + "full", + "show_cached", + "hide_all" + ] + }, + "public.ai_insight_cache_type": { + "name": "ai_insight_cache_type", + "schema": "public", + "values": [ + "dashboard", + "revenue", + "expense", + "scenario", + "funding", + "team", + "reports", + "general" + ] + }, + "public.ai_permission_mode": { + "name": "ai_permission_mode", + "schema": "public", + "values": [ + "ask", + "session", + "always" + ] + }, + "public.ai_provider_kind": { + "name": "ai_provider_kind", + "schema": "public", + "values": [ + "anthropic", + "openai", + "openrouter", + "ollama", + "google", + "mistral", + "groq", + "openai-compatible" + ] + }, + "public.ai_provider_model_source": { + "name": "ai_provider_model_source", + "schema": "public", + "values": [ + "fetched", + "manual", + "preset" + ] + }, + "public.ai_tool_audit_log_status": { + "name": "ai_tool_audit_log_status", + "schema": "public", + "values": [ + "success", + "error", + "validation_error", + "pending_apply" + ] + }, + "public.ai_tool_permission_decision": { + "name": "ai_tool_permission_decision", + "schema": "public", + "values": [ + "auto", + "granted_once", + "granted_session", + "denied" + ] + }, + "public.ai_turn_event_type": { + "name": "ai_turn_event_type", + "schema": "public", + "values": [ + "user_message", + "assistant_step", + "tool_result", + "scenario", + "gate", + "turn_done", + "turn_error" + ] + }, + "public.ai_write_mode": { + "name": "ai_write_mode", + "schema": "public", + "values": [ + "full", + "confirm", + "read_only" + ] + }, + "public.account_category": { + "name": "account_category", + "schema": "public", + "values": [ + "revenue", + "cogs", + "operating_expense", + "other_income", + "other_expense", + "asset", + "liability", + "equity" + ] + }, + "public.account_type": { + "name": "account_type", + "schema": "public", + "values": [ + "income", + "expense", + "asset", + "liability", + "equity" + ] + }, + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "import", + "rollback" + ] + }, + "public.audit_entity_type": { + "name": "audit_entity_type", + "schema": "public", + "values": [ + "transaction", + "financial_account", + "scenario", + "forecast_line", + "forecast_value", + "headcount_plan", + "revenue_stream", + "funding_round", + "import_batch", + "department", + "metric", + "salary_change", + "bonus", + "equity_grant", + "funding_round_investor", + "share_class", + "option_pool" + ] + }, + "public.bonus_type": { + "name": "bonus_type", + "schema": "public", + "values": [ + "signing", + "performance", + "retention", + "other" + ] + }, + "public.equity_grant_type": { + "name": "equity_grant_type", + "schema": "public", + "values": [ + "iso", + "nso", + "rsu" + ] + }, + "public.expense_frequency": { + "name": "expense_frequency", + "schema": "public", + "values": [ + "monthly", + "quarterly", + "annual" + ] + }, + "public.forecast_method": { + "name": "forecast_method", + "schema": "public", + "values": [ + "fixed", + "growth_rate", + "per_unit", + "percentage_of", + "custom_formula" + ] + }, + "public.funding_round_type": { + "name": "funding_round_type", + "schema": "public", + "values": [ + "pre_seed", + "seed", + "series_a", + "series_b", + "series_c_plus", + "debt", + "grant", + "safe", + "convertible" + ] + }, + "public.headcount_employee_type": { + "name": "headcount_employee_type", + "schema": "public", + "values": [ + "full_time", + "part_time", + "contractor" + ] + }, + "public.import_batch_status": { + "name": "import_batch_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "rolled_back", + "failed" + ] + }, + "public.metric_category": { + "name": "metric_category", + "schema": "public", + "values": [ + "financial", + "saas", + "growth", + "efficiency", + "custom" + ] + }, + "public.revenue_stream_type": { + "name": "revenue_stream_type", + "schema": "public", + "values": [ + "subscription", + "one_time", + "usage_based", + "services", + "marketplace", + "ecommerce", + "hardware" + ] + }, + "public.scenario_override_action": { + "name": "scenario_override_action", + "schema": "public", + "values": [ + "create", + "modify", + "delete" + ] + }, + "public.scenario_source": { + "name": "scenario_source", + "schema": "public", + "values": [ + "blank", + "ai", + "template", + "clone", + "backup" + ] + }, + "public.scenario_status": { + "name": "scenario_status", + "schema": "public", + "values": [ + "active", + "promoted", + "archived" + ] + }, + "public.share_class_type": { + "name": "share_class_type", + "schema": "public", + "values": [ + "common", + "preferred" + ] + }, + "public.transaction_source": { + "name": "transaction_source", + "schema": "public", + "values": [ + "manual", + "import", + "integration", + "forecast" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4435f080..26cdfefb 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1782709952361, "tag": "0015_shocking_lord_tyger", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1783150210962, + "tag": "0016_sad_moondragon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/__tests__/competitor.test.ts b/packages/db/src/__tests__/competitor.test.ts index e51dd274..6c12f740 100644 --- a/packages/db/src/__tests__/competitor.test.ts +++ b/packages/db/src/__tests__/competitor.test.ts @@ -115,4 +115,26 @@ describe("competitor queries", () => { await deleteCompetitor(c.id, companyId); expect(await getCompetitor(c.id, companyId)).toBeUndefined(); }); + + it("round-trips normalized text + hash on a snapshot", async () => { + const c = await createCompetitor({ companyId, name: "Acme", url: "https://acme.com" }); + const s = await createSource({ companyId, competitorId: c.id, type: "pricing", url: "u" }); + const snap = await insertSnapshot({ + companyId, + competitorId: c.id, + sourceId: s.id, + raw: "

Pro $29

", + rawHash: "rawhash", + structured: { plans: [] }, + structuredHash: "structhash", + normalized: "Pro $29", + normalizedHash: "normhash", + }); + expect(snap.normalized).toBe("Pro $29"); + expect(snap.normalizedHash).toBe("normhash"); + + const latest = await getLatestSnapshot(s.id); + expect(latest?.normalized).toBe("Pro $29"); + expect(latest?.normalizedHash).toBe("normhash"); + }); }); diff --git a/packages/db/src/queries/competitor.ts b/packages/db/src/queries/competitor.ts index 7e156b9c..ff3c90b0 100644 --- a/packages/db/src/queries/competitor.ts +++ b/packages/db/src/queries/competitor.ts @@ -158,6 +158,8 @@ export async function insertSnapshot(input: { rawHash: string; structured: unknown; structuredHash: string; + normalized?: string | null; + normalizedHash?: string | null; }): Promise { const [row] = await db .insert(competitorSnapshots) diff --git a/packages/db/src/schema/competitor.ts b/packages/db/src/schema/competitor.ts index 2f094dce..be803aec 100644 --- a/packages/db/src/schema/competitor.ts +++ b/packages/db/src/schema/competitor.ts @@ -84,6 +84,11 @@ export const competitorSnapshots = pgTable( rawHash: text("raw_hash").notNull(), structured: jsonb("structured").notNull(), structuredHash: text("structured_hash").notNull(), + // Tier-1 detection floor (spec §4): the normalized visible text + its hash. + // Nullable → additive, non-destructive; existing rows keep NULL and re-baseline + // once on the next run. Store-on-change now keys on normalizedHash, not rawHash. + normalized: text("normalized"), + normalizedHash: text("normalized_hash"), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }, (table) => [ From 4b70eb4f7039d036a647a554931632088e34efde Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:03:58 +0530 Subject: [PATCH 26/55] fix(competitor): make insertSnapshot normalized fields required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens insertSnapshot input from optional normalized?/normalizedHash? to required normalized: string / normalizedHash: string, restoring the compile-time guarantee that every stored snapshot carries a non-null normalizedHash — the field the Task-4 pipeline keys store-on-change on. Updates the 2 pre-existing snapshot test call sites to pass the fields. Co-Authored-By: Claude Opus 4.8 --- packages/db/src/__tests__/competitor.test.ts | 4 ++++ packages/db/src/queries/competitor.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/db/src/__tests__/competitor.test.ts b/packages/db/src/__tests__/competitor.test.ts index 6c12f740..8d5d614c 100644 --- a/packages/db/src/__tests__/competitor.test.ts +++ b/packages/db/src/__tests__/competitor.test.ts @@ -77,6 +77,8 @@ describe("competitor queries", () => { rawHash: "h1", structured: { v: 1 }, structuredHash: "sh1", + normalized: "n/a", + normalizedHash: "n/a", }); const latest = await getLatestSnapshot(s.id); expect(latest?.structuredHash).toBe("sh1"); @@ -93,6 +95,8 @@ describe("competitor queries", () => { rawHash: "h", structured: {}, structuredHash: "sh", + normalized: "n/a", + normalizedHash: "n/a", }); await insertChanges([ { diff --git a/packages/db/src/queries/competitor.ts b/packages/db/src/queries/competitor.ts index ff3c90b0..cf604e47 100644 --- a/packages/db/src/queries/competitor.ts +++ b/packages/db/src/queries/competitor.ts @@ -158,8 +158,8 @@ export async function insertSnapshot(input: { rawHash: string; structured: unknown; structuredHash: string; - normalized?: string | null; - normalizedHash?: string | null; + normalized: string; + normalizedHash: string; }): Promise { const [row] = await db .insert(competitorSnapshots) From 232f27d7cc3a8566aa3972386502aa8ac228fb75 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:10:10 +0530 Subject: [PATCH 27/55] feat(competitor): reframe pipeline to Tier-1 content-diff + confidence-gated Tier-2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (always): fetch → normalizeHtml → store-on-change keyed on normalizedHash (nonce-churn fix) → diffText → one content_changed alert. Tier 2 (only when a collector exists and parse confidence >= 0.4): diffStructured → typed changes. page-type sources have no collector (Tier 1 only); low-confidence/no-collector on a successful fetch is content_only, not broken. Co-Authored-By: Claude Opus 4.8 --- .../lib/competitor/__tests__/pipeline.test.ts | 165 +++++++++++------ apps/web/src/lib/competitor/pipeline.ts | 166 +++++++++++------- 2 files changed, 213 insertions(+), 118 deletions(-) diff --git a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts index b97b663a..d302df37 100644 --- a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts +++ b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts @@ -1,5 +1,5 @@ /** - * Integration test for the competitor sync pipeline. + * Integration test for the reframed competitor sync pipeline (spec §5). * * DB WIRING: imports @db-test factories → vitest.config.mts `needsDb()` detects * this file as a "db" test → vitest.setup.db.ts runs first and assigns @@ -7,67 +7,68 @@ * evaluates. When pipeline.ts (and its @burnless/db imports) are loaded, they * pick up the in-memory PGlite. The @burnless/db barrel is NOT mocked. * - * COLLECTOR MOCK: vi.mock("../collectors") stubs getCollector so no network - * calls happen. The stub's parse() reads globalThis.__next at call-time so - * tests can change the structured payload between runs. + * NETWORK: we do NOT mock ../collectors — the REAL collectors + real + * `normalizeHtml`/parse run. Both the structured collectors and the `page` + * fallback fetch through `httpFetch`, which calls `global.fetch`. We stub + * `global.fetch` with a sequenced-HTML helper so runs return controlled pages + * (nonce churn, visible changes, confident pricing) with zero network I/O. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; import { createUser, createCompany, createMember } from "@db-test/factories"; import { + db, + competitorSnapshots, createCompetitor, createSource, getLatestSnapshot, listChanges, } from "@burnless/db"; -// Stub the collector registry so no network calls happen. -// parse() reads globalThis.__next at call-time — set it before each runSource call. -vi.mock("../collectors", async (orig) => { - const actual = await (orig as () => Promise>)(); - return { - ...actual, - getCollector: () => ({ - type: "pricing", - fetch: async () => ({ - contentType: "text/html", - raw: "", - fetchedAt: new Date(), - status: 200, - }), - parse: () => ({ - structured: (globalThis as Record).__next, - confidence: 0.9, - }), - }), - }; -}); - -// Import AFTER the mock declaration so vitest hoisting applies. +// Import the real pipeline (collectors are real, only global.fetch is stubbed). import { runSource } from "../pipeline"; -const PRICE_29 = { - plans: [ - { - name: "Pro", - price: { amount: 29, currency: "USD", period: "month" }, - features: [], - }, - ], -}; - -const PRICE_39 = { - plans: [ - { - name: "Pro", - price: { amount: 39, currency: "USD", period: "month" }, - features: [], - }, - ], -}; +/** Stub global.fetch to return each HTML page in turn (last page repeats). */ +function stubFetchSequence(pages: string[]): void { + let i = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const raw = pages[Math.min(i, pages.length - 1)] ?? ""; + i += 1; + return { + status: 200, + headers: { get: () => "text/html" }, + text: async () => raw, + } as unknown as Response; + }), + ); +} + +/** Count stored snapshots for a source (store-on-change assertions). */ +async function countSnapshots(sourceId: string): Promise { + const rows = await db + .select() + .from(competitorSnapshots) + .where(eq(competitorSnapshots.sourceId, sourceId)); + return rows.length; +} + +/** HTML the pricing collector parses confidently (JSON-LD Product + visible price). */ +function pricingHtml(amount: number): string { + const ld = JSON.stringify({ + "@type": "Product", + name: "Pro", + offers: { price: amount, priceCurrency: "USD" }, + }); + return `
  • Pro $${amount}/mo
`; +} describe("competitor sync pipeline", () => { let companyId: string; + let competitorId: string; let sourceRow: Awaited>; + let pageSource: Awaited>; beforeEach(async () => { const user = await createUser(); @@ -81,16 +82,29 @@ describe("competitor sync pipeline", () => { name: "Acme", url: "https://acme.com", }); + competitorId = competitor.id; + sourceRow = await createSource({ companyId, competitorId: competitor.id, type: "pricing", url: "https://acme.com/pricing", }); + // A `page`-type source has NO collector → Tier 1 (content diff) only. + pageSource = await createSource({ + companyId, + competitorId: competitor.id, + type: "page", + url: "https://acme.com/features", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it("first run stores a snapshot and returns changed:true", async () => { - (globalThis as Record).__next = PRICE_29; + stubFetchSequence([pricingHtml(29)]); const result = await runSource(sourceRow); @@ -100,10 +114,11 @@ describe("competitor sync pipeline", () => { const snap = await getLatestSnapshot(sourceRow.id); expect(snap).toBeTruthy(); expect(snap!.structuredHash).toBeTruthy(); + expect(snap!.normalizedHash).toBeTruthy(); }); it("unchanged re-run creates NO new snapshot (store-on-change guard)", async () => { - (globalThis as Record).__next = PRICE_29; + stubFetchSequence([pricingHtml(29), pricingHtml(29)]); // First run — stores snapshot await runSource(sourceRow); @@ -117,15 +132,16 @@ describe("competitor sync pipeline", () => { const snapAfterSecond = await getLatestSnapshot(sourceRow.id); // Still the same snapshot row expect(snapAfterSecond!.id).toBe(snapAfterFirst!.id); + expect(await countSnapshots(sourceRow.id)).toBe(1); }); it("changed price stores a new snapshot and emits a price_increase change row", async () => { + stubFetchSequence([pricingHtml(29), pricingHtml(39)]); + // Run 1: establish baseline at price 29 - (globalThis as Record).__next = PRICE_29; await runSource(sourceRow); // Run 2: price bumped to 39 — should diff and emit price_increase - (globalThis as Record).__next = PRICE_39; const result = await runSource(sourceRow); expect(result.changed).toBe(true); @@ -137,4 +153,51 @@ describe("competitor sync pipeline", () => { expect(changes.length).toBeGreaterThan(0); expect(changes.some((c) => c.changeType === "price_increase")).toBe(true); }); + + it("does NOT store a new snapshot when only a nonce changed (normalized content identical)", async () => { + // Identical VISIBLE text, rotated nonce attribute between the two fetches. + const pageA = `
  • Pro $29
`; + const pageB = `
  • Pro $29
`; + stubFetchSequence([pageA, pageB]); + + await runSource(sourceRow); // first run stores baseline + const before = await countSnapshots(sourceRow.id); + const r = await runSource(sourceRow); // second run: nonce-only churn + const after = await countSnapshots(sourceRow.id); + + expect(after).toBe(before); + expect(r.changed).toBe(false); + expect(r.broken).toBe(false); + }); + + it("emits ONE content change on a real visible change (page source, no structured parse)", async () => { + const pageA = `
Welcome
`; + const pageB = `
Welcome — now with SSO
`; + stubFetchSequence([pageA, pageB]); + + await runSource(pageSource); // a source of type "page" (no collector) + const r = await runSource(pageSource); + + expect(r.changed).toBe(true); + expect(r.broken).toBe(false); + + const changes = await listChanges(companyId, { competitorId }); + const content = changes.filter((c) => c.changeType === "content_changed"); + expect(content).toHaveLength(1); + expect(content[0]!.severity).toBe("info"); + // no typed pricing/social change on a page source + expect(changes.every((c) => c.changeType === "content_changed")).toBe(true); + }); + + it("emits BOTH a content change and a typed price change when pricing parse is confident", async () => { + stubFetchSequence([pricingHtml(29), pricingHtml(39)]); + + await runSource(sourceRow); + const r = await runSource(sourceRow); + + const changes = await listChanges(companyId, { competitorId }); + expect(changes.some((c) => c.changeType === "content_changed")).toBe(true); + expect(changes.some((c) => c.changeType === "price_increase")).toBe(true); + expect(r.changed).toBe(true); + }); }); diff --git a/apps/web/src/lib/competitor/pipeline.ts b/apps/web/src/lib/competitor/pipeline.ts index 9bbfd2c5..f4d7e08a 100644 --- a/apps/web/src/lib/competitor/pipeline.ts +++ b/apps/web/src/lib/competitor/pipeline.ts @@ -1,5 +1,12 @@ import { sha256hex } from "@burnless/db"; -import { diffStructured, evaluateRules, type Alert } from "@burnless/engine"; +import { + diffStructured, + evaluateRules, + diffText, + normalizeHtml, + contentChangeAlert, + type Alert, +} from "@burnless/engine"; import type { CompetitorSource } from "@burnless/db"; import { getDueSources, @@ -11,34 +18,40 @@ import { createNotification, getCompanyNotifyUserIds, } from "@burnless/db"; -import { getCollector, type CollectorSource } from "./collectors"; +import { getCollector, httpFetch, type CollectorSource, type RawCapture } from "./collectors"; import { buildDigest } from "./notify"; +/** Human page label for the Tier-1 content-change summary. */ +const SOURCE_LABELS: Record = { + pricing: "Pricing page", + social: "Social page", + page: "Page", +}; +function sourceLabel(type: string): string { + return SOURCE_LABELS[type] ?? "Page"; +} + /** - * Run a single competitor source through the full pipeline: - * fetch → parse → hash → store-on-change → diff → rules → insert changes → notify. + * Run a single competitor source through the reframed pipeline (spec §5): + * fetch → Tier 1 (normalize → content hash → store-on-change → text-diff → + * generic `content` change) → Tier 2 (confident structured parse → typed + * changes) → rules → insert changes → notify. * - * Returns: - * - changed: true if a new snapshot was stored (structured data changed) - * - broken: true if fetch/parse/collector failed - * - alerts: the rule alerts emitted (empty when no previous snapshot to diff against) + * Tier 1 ALWAYS runs (source-type-agnostic; `page` sources have no collector). + * Store-on-change is keyed on the NORMALIZED content hash (not rawHash), so + * nonce/CSRF/analytics churn in the raw HTML no longer counts as a change. */ export async function runSource( source: CompetitorSource, ): Promise<{ changed: boolean; broken: boolean; alerts: Alert[] }> { const collector = getCollector(source.type); - if (!collector) { - await updateSource(source.id, source.companyId, { - lastRunAt: new Date(), - lastStatus: "no_collector", - healthState: "broken", - }); - return { changed: false, broken: true, alerts: [] }; - } - let raw: Awaited>; + // ── Fetch (Tier 1 needs the raw regardless of a structured collector). ── + let raw: RawCapture; try { - raw = await collector.fetch(source as unknown as CollectorSource); + raw = collector + ? await collector.fetch(source as unknown as CollectorSource) + : await httpFetch(source.url); } catch (e) { await updateSource(source.id, source.companyId, { lastRunAt: new Date(), @@ -48,24 +61,34 @@ export async function runSource( return { changed: false, broken: true, alerts: [] }; } - const parsed = collector.parse(raw, source as unknown as CollectorSource); - if (parsed.confidence < 0.4) { - await updateSource(source.id, source.companyId, { - lastRunAt: new Date(), - lastStatus: "low_confidence", - healthState: "broken", - }); - return { changed: false, broken: true, alerts: [] }; + // ── Tier 1: normalize → content hash. ── + const normalized = normalizeHtml(raw.raw); + const normalizedHash = sha256hex(normalized); + + // ── Tier 2 (optional, confidence-gated): structured extraction. ── + let structured: unknown = {}; + let parseConfident = false; + if (collector) { + const parsed = collector.parse(raw, source as unknown as CollectorSource); + if (parsed.confidence >= 0.4) { + structured = parsed.structured; + parseConfident = true; + } } + const structuredHash = sha256hex(JSON.stringify(structured)); - const structuredHash = sha256hex(JSON.stringify(parsed.structured)); const latest = await getLatestSnapshot(source.id); + const okStatus = parseConfident ? "ok" : "content_only"; - // Store-on-change: skip if nothing changed. - if (latest && latest.structuredHash === structuredHash) { + // ── Store-on-change: skip only when BOTH content AND structured are same. ── + if ( + latest && + latest.normalizedHash === normalizedHash && + latest.structuredHash === structuredHash + ) { await updateSource(source.id, source.companyId, { lastRunAt: new Date(), - lastStatus: "ok", + lastStatus: okStatus, healthState: "ok", }); return { changed: false, broken: false, alerts: [] }; @@ -77,52 +100,61 @@ export async function runSource( sourceId: source.id, raw: raw.raw, rawHash: sha256hex(raw.raw), - structured: parsed.structured, + structured, structuredHash, + normalized, + normalizedHash, }); - let alerts: Alert[] = []; - - // Only diff when a previous snapshot exists. + const alerts: Alert[] = []; if (latest) { - const changes = diffStructured(latest.structured, parsed.structured); - alerts = evaluateRules(source.type, changes); - - if (alerts.length > 0) { - await insertChanges( - alerts.map((a) => ({ - companyId: source.companyId, - competitorId: source.competitorId, - sourceId: source.id, - snapshotId: snapshot.id, - changeType: a.changeType, - summary: a.summary, - before: a.before ?? null, - after: a.after ?? null, - severity: a.severity, - })), - ); - - const competitor = await getCompetitor(source.competitorId, source.companyId); - const digest = buildDigest(competitor?.name ?? "competitor", alerts); - const userIds = await getCompanyNotifyUserIds(source.companyId); - for (const userId of userIds) { - await createNotification({ - companyId: source.companyId, - userId, - category: "competitor", - title: digest.title, - body: digest.body, - severity: digest.severity, - link: `/competitors/${source.competitorId}`, - }); - } + // Tier 1 — generic content change (works on any page). + const contentAlert = contentChangeAlert( + diffText(latest.normalized ?? "", normalized), + sourceLabel(source.type), + ); + if (contentAlert) alerts.push(contentAlert); + + // Tier 2 — typed structured changes, only when we have a confident parse. + if (parseConfident) { + alerts.push(...evaluateRules(source.type, diffStructured(latest.structured, structured))); + } + } + + if (alerts.length > 0) { + await insertChanges( + alerts.map((a) => ({ + companyId: source.companyId, + competitorId: source.competitorId, + sourceId: source.id, + snapshotId: snapshot.id, + changeType: a.changeType, + summary: a.summary, + before: a.before ?? null, + after: a.after ?? null, + severity: a.severity, + })), + ); + + const competitor = await getCompetitor(source.competitorId, source.companyId); + const digest = buildDigest(competitor?.name ?? "competitor", alerts); + const userIds = await getCompanyNotifyUserIds(source.companyId); + for (const userId of userIds) { + await createNotification({ + companyId: source.companyId, + userId, + category: "competitor", + title: digest.title, + body: digest.body, + severity: digest.severity, + link: `/competitors/${source.competitorId}`, + }); } } await updateSource(source.id, source.companyId, { lastRunAt: new Date(), - lastStatus: "ok", + lastStatus: okStatus, healthState: "ok", }); return { changed: true, broken: false, alerts }; From 291ec6f4d193497c849ac9be5b31f59bfa66043c Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:17:12 +0530 Subject: [PATCH 28/55] feat(competitor): accept generic page-watcher source type in route schemas Widens the source-type Zod enum in both the POST /api/competitors body schema and the POST /api/competitors/[id]/sources schema from ["pricing","social"] to ["pricing","social","page"], allowing callers to register a generic page-watcher source (Tier-1-only, no collector needed). Adds a TDD test that POST with a page-type source returns 201. Co-Authored-By: Claude Opus 4.8 --- .../app/api/competitors/[id]/sources/route.ts | 2 +- .../competitors/__tests__/competitors.test.ts | 25 +++++++++++++++++++ apps/web/src/app/api/competitors/route.ts | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/api/competitors/[id]/sources/route.ts b/apps/web/src/app/api/competitors/[id]/sources/route.ts index b1d8a6e7..44aa8d7b 100644 --- a/apps/web/src/app/api/competitors/[id]/sources/route.ts +++ b/apps/web/src/app/api/competitors/[id]/sources/route.ts @@ -6,7 +6,7 @@ import { requireCompanyAccess, requireCompanyWrite, parseBody, withErrorHandler import { requireDomainEnabled } from "@/lib/domain-gating"; const createSourceSchema = z.object({ - type: z.enum(["pricing", "social"]), + type: z.enum(["pricing", "social", "page"]), url: z.string().url(), config: z.unknown().optional(), intervalHours: z.number().int().positive().optional(), diff --git a/apps/web/src/app/api/competitors/__tests__/competitors.test.ts b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts index f40f2621..5821dcd0 100644 --- a/apps/web/src/app/api/competitors/__tests__/competitors.test.ts +++ b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts @@ -197,6 +197,31 @@ describe("POST /api/competitors", () => { }); }); + it("accepts a `page` source when creating a competitor", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + }); + mockCreateSource.mockResolvedValue({ id: "src-1" }); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Rival", + url: "https://rival.com", + sources: [{ type: "page", url: "https://rival.com/changelog" }], + }), + }), + ); + expect(res.status).toBe(201); + }); + it("returns 400 for invalid body (missing url)", async () => { mockRequireWrite.mockResolvedValue(validCtx); mockRequireDomainEnabled.mockResolvedValue(null); diff --git a/apps/web/src/app/api/competitors/route.ts b/apps/web/src/app/api/competitors/route.ts index 7a134a21..439488c3 100644 --- a/apps/web/src/app/api/competitors/route.ts +++ b/apps/web/src/app/api/competitors/route.ts @@ -11,7 +11,7 @@ const createCompetitorSchema = z.object({ sources: z .array( z.object({ - type: z.enum(["pricing", "social"]), + type: z.enum(["pricing", "social", "page"]), url: z.string().url(), config: z.unknown().optional(), }), From ff7767ceec08ef1176b4078d9a2a4fd0090ec3af Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:20:36 +0530 Subject: [PATCH 29/55] test(competitor): allowlist reframe test dirs in no-hardcoded-currency guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tier-1/Tier-2 reframe tests embed $29/$39/$${amount} inside sample competitor HTML (fetched+normalized+diffed) and expected normalized-text assertions — third-party scraped-price INPUT, not app display code. Same rationale as the already-allowlisted pricing.ts collector. Adds the three competitor test dirs so pnpm check's currency guard stays green. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/__tests__/no-hardcoded-currency.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/__tests__/no-hardcoded-currency.test.ts b/apps/web/src/__tests__/no-hardcoded-currency.test.ts index 86438f87..fba5e4f4 100644 --- a/apps/web/src/__tests__/no-hardcoded-currency.test.ts +++ b/apps/web/src/__tests__/no-hardcoded-currency.test.ts @@ -55,6 +55,17 @@ const ALLOWED = [ // symbolToCurrency map normalises parsed glyphs to ISO codes. Not display code. "apps/web/src/lib/competitor/collectors/pricing.ts", + // ── Competitor collector-reframe tests: scraped-HTML fixtures ───────────── + // The Tier-1/Tier-2 tests use `$29`/`$39`/`$${amount}` INSIDE sample + // competitor HTML strings (what we fetch + normalize + diff) and as expected + // normalized-text assertions — third-party scraped-price INPUT, never app + // display code. Same rationale as the pricing.ts collector entry above. + // pipeline.test.ts (Tier-1/2 HTML fixtures), engine normalize/text-diff tests + // (normalized-text fixtures), db competitor.test.ts (snapshot raw/normalized). + "apps/web/src/lib/competitor/__tests__/", + "packages/engine/src/competitor/__tests__/", + "packages/db/src/__tests__/", + // ── CSV import parser ───────────────────────────────────────────────────── // import-flow.tsx line 134: /[$,€£()]/ in a regex to STRIP currency // characters from user-supplied CSV amounts. Not display code. From 89e8b1557cb2f72a7381d6b3c0a716480100f196 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:23:19 +0530 Subject: [PATCH 30/55] test(competitor): scope db currency-guard allowlist to competitor.test.ts Review Minor: file-scope the db __tests__ allowlist entry to the single file that holds scraped-price fixtures rather than the whole directory. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/__tests__/no-hardcoded-currency.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/__tests__/no-hardcoded-currency.test.ts b/apps/web/src/__tests__/no-hardcoded-currency.test.ts index fba5e4f4..155879e4 100644 --- a/apps/web/src/__tests__/no-hardcoded-currency.test.ts +++ b/apps/web/src/__tests__/no-hardcoded-currency.test.ts @@ -64,7 +64,9 @@ const ALLOWED = [ // (normalized-text fixtures), db competitor.test.ts (snapshot raw/normalized). "apps/web/src/lib/competitor/__tests__/", "packages/engine/src/competitor/__tests__/", - "packages/db/src/__tests__/", + // File-scoped (not the whole db __tests__ dir): only competitor.test.ts holds + // scraped-price fixtures; the DB layer has no display code to guard elsewhere. + "packages/db/src/__tests__/competitor.test.ts", // ── CSV import parser ───────────────────────────────────────────────────── // import-flow.tsx line 134: /[$,€£()]/ in a regex to STRIP currency From 282c988047c6f9a83127fc415ff85f6d968b27dc Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:27:09 +0530 Subject: [PATCH 31/55] refactor(competitor): extract shared analysis body + content-change snippet Move the Sources (+ "Sync now"), Latest pricing, and Activity timeline JSX out of the [id] profile view into a new shared client component CompetitorAnalysisBody, so it can also mount on the dashboard card (Task 8). Add an inline
disclosure (existing tokens, no new primitive) that reveals a content_changed change's added/removed line snippet. The profile view is now a thin header wrapper and keeps re-exporting the DTO types. Co-Authored-By: Claude Opus 4.8 --- .../[id]/competitor-profile-view.tsx | 253 ++---------------- .../competitor-analysis-body.test.tsx | 40 +++ .../competitors/competitor-analysis-body.tsx | 211 +++++++++++++++ 3 files changed, 270 insertions(+), 234 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx create mode 100644 apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx index 7c135abd..9fb11e52 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -8,26 +8,22 @@ * fallbackData) for a live-updating timeline, and inline token-styled status * spans following the StatusPill precedent (no new badge component). * - * Pricing snapshots are competitor-scraped data; prices are rendered via - * formatCurrency() from @burnless/types using the plan's OWN scraped currency - * (falling back to the company currency) — never a hardcoded symbol and never - * forcing the company currency onto a competitor's price. + * This view is now a thin wrapper: its own back-link + name + external-URL + * header, plus the shared which renders the Sources + * (+ "Sync now"), Latest pricing, and Activity timeline. The same body is + * mounted on the dashboard card, so the analysis renders identically in both. + * + * Pricing snapshots are competitor-scraped data; prices are rendered (inside + * the shared body) via formatCurrency() from @burnless/types using the plan's + * OWN scraped currency (falling back to the company currency) — never a + * hardcoded symbol and never forcing the company currency onto a competitor's + * price. */ -import { useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { ArrowLeft, ExternalLink, RefreshCw } from "lucide-react"; -import { DataTable, Button, WidgetCard } from "@/components/ui"; -import { useLocale } from "@/components/locale/locale-context"; -import { formatCurrency, isValidCurrency, type CurrencyCode } from "@burnless/types"; -import { apiFetch } from "@/lib/api-fetch"; -import { toUserMessage } from "@/lib/api-error"; -import { - useCompetitorChanges, - type CompetitorChangeDto, - type CompetitorChangesPayload, -} from "@/lib/swr"; +import { ArrowLeft, ExternalLink } from "lucide-react"; +import type { CompetitorChangesPayload } from "@/lib/swr"; +import { CompetitorAnalysisBody } from "../competitor-analysis-body"; // ── JSON-safe prop DTOs (Date → ISO string; mirrors the list-page DTO style) ── @@ -70,44 +66,6 @@ interface CompetitorProfileViewProps { initialChanges: CompetitorChangesPayload; } -// ── Inline token-styled status spans (StatusPill precedent — NOT new -// @/components/ui components; same pattern as the Task-14 list view). ─────── - -const PILL_BASE = - "inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium uppercase"; - -/** Source health: "broken" → danger ("Needs attention"), else success ("Healthy"). */ -function HealthBadge({ - healthState, - lastStatus, -}: { - healthState: string; - lastStatus: string | null; -}) { - if (healthState === "broken") { - return ( - - Needs attention - - ); - } - return Healthy; -} - -/** Change severity: critical → danger, warning → warning, else (info) → surface. */ -function SeverityBadge({ severity }: { severity: string }) { - const cls = - severity === "critical" - ? "bg-danger-50 text-danger-600" - : severity === "warning" - ? "bg-warning-50 text-warning-700" - : "bg-surface-100 text-surface-600"; - return {severity}; -} - // ── View ──────────────────────────────────────────────────────────────────── export function CompetitorProfileView({ @@ -116,98 +74,6 @@ export function CompetitorProfileView({ latestSnapshots, initialChanges, }: CompetitorProfileViewProps) { - const router = useRouter(); - const { fmtDate, currency, locale } = useLocale(); - const { data, mutate } = useCompetitorChanges(competitor.id, { - fallbackData: initialChanges, - }); - - const [syncing, setSyncing] = useState(false); - const [syncError, setSyncError] = useState(null); - - const changes = data?.changes ?? initialChanges.changes; - - // First pricing source that produced a snapshot with plans → the headline - // pricing card. Snapshots are aligned by index with `sources`. - const pricingPlans = (() => { - for (let i = 0; i < sources.length; i++) { - if (sources[i]?.type !== "pricing") continue; - const plans = latestSnapshots[i]?.structured?.plans; - if (plans && plans.length > 0) return plans; - } - return null; - })(); - - async function handleSync() { - setSyncError(null); - setSyncing(true); - try { - const res = await apiFetch(`/api/competitors/${competitor.id}/sync`, { - method: "POST", - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? "Failed to sync"); - } - // Refresh the live changes timeline AND the RSC-sourced source health / - // last-checked / latest-snapshot props. - void mutate(); - router.refresh(); - } catch (err) { - setSyncError(toUserMessage(err)); - } finally { - setSyncing(false); - } - } - - const changeColumns = [ - { - key: "severity", - header: "Severity", - render: (c: CompetitorChangeDto) => , - sortValue: (c: CompetitorChangeDto) => c.severity, - }, - { - key: "summary", - header: "Change", - render: (c: CompetitorChangeDto) => ( - {c.summary} - ), - }, - { - key: "detectedAt", - header: "Detected", - align: "right" as const, - render: (c: CompetitorChangeDto) => ( - {fmtDate(c.detectedAt)} - ), - sortValue: (c: CompetitorChangeDto) => c.detectedAt, - }, - ]; - - const planColumns = [ - { - key: "name", - header: "Plan", - render: (p: PlanDto) => {p.name}, - }, - { - key: "price", - header: "Price", - align: "right" as const, - render: (p: PlanDto) => ( - - {p.price.amount != null - ? formatCurrency(p.price.amount, (isValidCurrency(p.price.currency ?? "") ? p.price.currency as CurrencyCode : currency), locale) - : "—"} - {p.price.period ? ( - {` / ${p.price.period}`} - ) : null} - - ), - }, - ]; - return (
@@ -236,93 +102,12 @@ export function CompetitorProfileView({
- {syncError && ( -
- {syncError} -
- )} - - {/* Sources ─────────────────────────────────────────────────────────── */} -
-

Sources

- {sources.length === 0 ? ( -

No sources tracked for this competitor.

- ) : ( -
- {sources.map((source) => ( - -
-
-
- - {source.type} - - -
- - {source.url} - - -

- {source.lastRunAt - ? `Last checked ${fmtDate(source.lastRunAt)}` - : "Never checked"} -

-
- -
-
- ))} -
- )} -
- - {/* Latest pricing snapshot ─────────────────────────────────────────── */} - {pricingPlans && ( -
-

Latest pricing

-
- p.name} - emptyMessage="No pricing plans captured." - /> -
-
- )} - - {/* Change timeline ─────────────────────────────────────────────────── */} -
-

Activity

-
- c.id} - emptyMessage="No changes detected yet." - /> -
-
+ ); } diff --git a/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx b/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx new file mode 100644 index 00000000..aeeb387a --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { CompetitorAnalysisBody } from "../competitor-analysis-body"; + +// WidgetCard (design-system) calls useRouter — mount a stub app router. +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); + +// happy-dom + SWR fallbackData → no fetch needed. +const base = { + competitor: { id: "c1", name: "Acme", url: "https://acme.com" }, + sources: [ + { id: "s1", type: "page", url: "https://acme.com/x", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" }, + ], + latestSnapshots: [null], +}; + +describe("CompetitorAnalysisBody", () => { + it("renders a content_changed row with its summary and an expandable snippet", () => { + const initialChanges = { + changes: [ + { + id: "ch1", competitorId: "c1", sourceId: "s1", snapshotId: "sn1", companyId: "co1", + detectedAt: new Date("2026-07-01").toISOString(), + changeType: "content_changed", + summary: "Page content changed: +1 / -1 lines", + before: { lines: ["Old line"], truncated: false }, + after: { lines: ["New line"], truncated: false }, + severity: "info", acknowledgedAt: null, createdAt: new Date("2026-07-01").toISOString(), + }, + ], + }; + render(); + expect(screen.getByText(/Page content changed/)).toBeTruthy(); + // Disclosure content present (details/summary): added + removed lines. + // The snippet renders each line with a diff prefix ("+ New line" / "- Old + // line"), so match with a regex rather than an exact string. + expect(screen.getByText(/New line/)).toBeTruthy(); + expect(screen.getByText(/Old line/)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx new file mode 100644 index 00000000..5532e1cd --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx @@ -0,0 +1,211 @@ +"use client"; + +/** + * Shared competitor analysis body (Tiers-1+2 reframe): Sources (+ "Sync now"), + * Latest pricing, and the Activity timeline. Mounted in two places — the + * /competitors/[id] permalink (with a header) and the dashboard card (expanded) + * — so the analysis renders identically in both. Design-system components only; + * a `content_changed` change gets an inline
disclosure (existing + * tokens, no new component) showing the added/removed line snippet. + */ + +import { useState } from "react"; +import { ExternalLink, RefreshCw } from "lucide-react"; +import { DataTable, Button, WidgetCard } from "@/components/ui"; +import { useLocale } from "@/components/locale/locale-context"; +import { formatCurrency, isValidCurrency, type CurrencyCode } from "@burnless/types"; +import { apiFetch } from "@/lib/api-fetch"; +import { toUserMessage } from "@/lib/api-error"; +import { + useCompetitorChanges, + type CompetitorChangeDto, + type CompetitorChangesPayload, +} from "@/lib/swr"; +import type { + CompetitorProfileDto, + SourceDto, + SnapshotDto, +} from "./[id]/competitor-profile-view"; + +const PILL_BASE = + "inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium uppercase"; + +function HealthBadge({ healthState, lastStatus }: { healthState: string; lastStatus: string | null }) { + if (healthState === "broken") { + return ( + + Needs attention + + ); + } + return Healthy; +} + +function SeverityBadge({ severity }: { severity: string }) { + const cls = + severity === "critical" + ? "bg-danger-50 text-danger-600" + : severity === "warning" + ? "bg-warning-50 text-warning-700" + : "bg-surface-100 text-surface-600"; + return {severity}; +} + +/** Content-change line snippet (added green / removed red) — reuse tokens only. */ +function ContentSnippet({ change }: { change: CompetitorChangeDto }) { + const before = (change.before as { lines?: string[]; truncated?: boolean } | null) ?? null; + const after = (change.after as { lines?: string[]; truncated?: boolean } | null) ?? null; + const removed = before?.lines ?? []; + const added = after?.lines ?? []; + if (removed.length === 0 && added.length === 0) return null; + return ( +
+ + View changed lines + +
+ {removed.map((l, i) => ( +
- {l}
+ ))} + {added.map((l, i) => ( +
+ {l}
+ ))} + {(before?.truncated || after?.truncated) && ( +
… more lines truncated
+ )} +
+
+ ); +} + +interface Props { + competitor: CompetitorProfileDto; + sources: SourceDto[]; + latestSnapshots: (SnapshotDto | null)[]; + initialChanges: CompetitorChangesPayload; +} + +export function CompetitorAnalysisBody({ competitor, sources, latestSnapshots, initialChanges }: Props) { + const { fmtDate, currency, locale } = useLocale(); + const { data, mutate } = useCompetitorChanges(competitor.id, { fallbackData: initialChanges }); + const [syncing, setSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); + + const changes = data?.changes ?? initialChanges.changes; + + const pricingPlans = (() => { + for (let i = 0; i < sources.length; i++) { + if (sources[i]?.type !== "pricing") continue; + const plans = latestSnapshots[i]?.structured?.plans; + if (plans && plans.length > 0) return plans; + } + return null; + })(); + + async function handleSync() { + setSyncError(null); + setSyncing(true); + try { + const res = await apiFetch(`/api/competitors/${competitor.id}/sync`, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to sync"); + } + void mutate(); + } catch (err) { + setSyncError(toUserMessage(err)); + } finally { + setSyncing(false); + } + } + + const planColumns = [ + { key: "name", header: "Plan", render: (p: { name: string }) => {p.name} }, + { + key: "price", + header: "Price", + align: "right" as const, + render: (p: { price: { amount: number | null; currency: string | null; period: string | null } }) => ( + + {p.price.amount != null + ? formatCurrency(p.price.amount, isValidCurrency(p.price.currency ?? "") ? (p.price.currency as CurrencyCode) : currency, locale) + : "—"} + {p.price.period ? {` / ${p.price.period}`} : null} + + ), + }, + ]; + + const changeColumns = [ + { key: "severity", header: "Severity", render: (c: CompetitorChangeDto) => , sortValue: (c: CompetitorChangeDto) => c.severity }, + { + key: "summary", + header: "Change", + render: (c: CompetitorChangeDto) => ( +
+ {c.summary} + {c.changeType === "content_changed" && } +
+ ), + }, + { key: "detectedAt", header: "Detected", align: "right" as const, render: (c: CompetitorChangeDto) => {fmtDate(c.detectedAt)}, sortValue: (c: CompetitorChangeDto) => c.detectedAt }, + ]; + + return ( +
+ {syncError && ( +
+ {syncError} +
+ )} + +
+

Sources

+ {sources.length === 0 ? ( +

No sources tracked for this competitor.

+ ) : ( +
+ {sources.map((source) => ( + +
+
+
+ {source.type} + +
+ + {source.url} + + +

+ {source.lastRunAt ? `Last checked ${fmtDate(source.lastRunAt)}` : "Never checked"} +

+
+ +
+
+ ))} +
+ )} +
+ + {pricingPlans && ( +
+

Latest pricing

+
+ p.name} emptyMessage="No pricing plans captured." /> +
+
+ )} + +
+

Activity

+
+ c.id} emptyMessage="No changes detected yet." /> +
+
+
+ ); +} From 52debe4b7795b439d23e796e408cb03123415423 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:29:16 +0530 Subject: [PATCH 32/55] fix(competitor): restore RSC refresh on sync in shared analysis body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSync now calls router.refresh() alongside mutate(), matching the pre-refactor profile view. mutate() revalidates the SWR changes timeline; router.refresh() re-runs the RSC props (source health / last-checked / latest-pricing snapshot) which are not SWR-sourced — otherwise "Sync now" leaves them stale until a manual reload. Correct for both mounts (the [id] permalink and the Task-8 dashboard card). Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/competitors/competitor-analysis-body.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx index 5532e1cd..6c45ce78 100644 --- a/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx +++ b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx @@ -10,6 +10,7 @@ */ import { useState } from "react"; +import { useRouter } from "next/navigation"; import { ExternalLink, RefreshCw } from "lucide-react"; import { DataTable, Button, WidgetCard } from "@/components/ui"; import { useLocale } from "@/components/locale/locale-context"; @@ -86,6 +87,7 @@ interface Props { } export function CompetitorAnalysisBody({ competitor, sources, latestSnapshots, initialChanges }: Props) { + const router = useRouter(); const { fmtDate, currency, locale } = useLocale(); const { data, mutate } = useCompetitorChanges(competitor.id, { fallbackData: initialChanges }); const [syncing, setSyncing] = useState(false); @@ -111,7 +113,12 @@ export function CompetitorAnalysisBody({ competitor, sources, latestSnapshots, i const body = await res.json().catch(() => ({})); throw new Error(body.error ?? "Failed to sync"); } + // mutate() refreshes the SWR changes timeline; router.refresh() re-runs + // the RSC props (source health / last-checked / latest-pricing snapshot), + // which don't come from SWR — otherwise "Sync now" leaves them stale + // until a manual reload. Correct for both mounts (permalink + dashboard). void mutate(); + router.refresh(); } catch (err) { setSyncError(toUserMessage(err)); } finally { From 5d17403e481743a9beae3acb368d4135fd471a7d Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:34:56 +0530 Subject: [PATCH 33/55] feat(competitor): analysis dashboard with expandable per-competitor cards Co-Authored-By: Claude Opus 4.8 --- .../competitors/competitor-card.tsx | 76 +++++++++++++++++++ .../competitors/competitors-dashboard.tsx | 48 ++++++++++++ .../src/app/(dashboard)/competitors/page.tsx | 73 +++++++++++------- 3 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/competitors/competitor-card.tsx create mode 100644 apps/web/src/app/(dashboard)/competitors/competitors-dashboard.tsx diff --git a/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx b/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx new file mode 100644 index 00000000..cebf0fee --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react"; +import { WidgetCard } from "@/components/ui"; +import { useLocale } from "@/components/locale/locale-context"; +import { CompetitorAnalysisBody } from "./competitor-analysis-body"; +import type { CompetitorProfileDto, SourceDto, SnapshotDto } from "./[id]/competitor-profile-view"; +import type { CompetitorChangesPayload, CompetitorChangeDto } from "@/lib/swr"; + +export interface CompetitorCardData { + competitor: CompetitorProfileDto; + sources: SourceDto[]; + latestSnapshots: (SnapshotDto | null)[]; + initialChanges: CompetitorChangesPayload; + changeCount30d: number; + latestChange: CompetitorChangeDto | null; +} + +export function CompetitorCard({ data }: { data: CompetitorCardData }) { + const { fmtDate } = useLocale(); + const [open, setOpen] = useState(false); + const { competitor, changeCount30d, latestChange } = data; + + return ( + + + + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/competitors/competitors-dashboard.tsx b/apps/web/src/app/(dashboard)/competitors/competitors-dashboard.tsx new file mode 100644 index 00000000..1d1291c9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/competitors-dashboard.tsx @@ -0,0 +1,48 @@ +"use client"; + +/** + * Competitor analysis dashboard (reframe): one expandable card per competitor, + * an at-a-glance recent-change-activity headline collapsed, full analysis on + * expand. Management (CRUD) lives at /competitors/manage — mirrors the + * transactions → accounts nesting. Design-system components + tokens only. + */ + +import Link from "next/link"; +import { Swords, Settings2 } from "lucide-react"; +import { Button, DataEmptyState } from "@/components/ui"; +import { CompetitorCard, type CompetitorCardData } from "./competitor-card"; + +export type { CompetitorCardData }; + +export function CompetitorsDashboard({ cards }: { cards: CompetitorCardData[] }) { + return ( +
+
+
+

Competitors

+

+ Continuous analysis of each competitor — pricing, social, and page changes +

+
+ + + +
+ + {cards.length === 0 ? ( + } + /> + ) : ( +
+ {cards.map((card) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/competitors/page.tsx b/apps/web/src/app/(dashboard)/competitors/page.tsx index 6548f2ea..04f5c85b 100644 --- a/apps/web/src/app/(dashboard)/competitors/page.tsx +++ b/apps/web/src/app/(dashboard)/competitors/page.tsx @@ -3,47 +3,64 @@ export const revalidate = 0; import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { listCompetitors } from "@burnless/db"; +import { listCompetitors, listSources, listChanges, getLatestSnapshot } from "@burnless/db"; import { getCompany } from "@/lib/data"; import { isDomainEnabled } from "@/lib/domain-gating"; import { SetupPrompt } from "@/components/ui/empty-state"; import { ReportContentSkeleton } from "@/components/reports/report-skeleton"; -import { CompetitorsView } from "./competitors-view"; -import type { CompetitorsPayload } from "@/lib/swr"; +import { CompetitorsDashboard, type CompetitorCardData } from "./competitors-dashboard"; +import type { CompetitorChangesPayload } from "@/lib/swr"; +import type { CompetitorProfileDto, SourceDto, SnapshotDto } from "./[id]/competitor-profile-view"; + +const THIRTY_DAYS_MS = 30 * 24 * 3_600_000; export default async function CompetitorsPage() { const company = await getCompany(); if (!company) return ; - - // Page-level domain gate — mirrors the requireDomainEnabled guard the REST - // routes use. If the competitor domain is off for this company/deployment, - // the route 404s (same surface the disabled nav entry implies). - if (!(await isDomainEnabled("competitor", { companyId: company.id }))) { - notFound(); - } - + if (!(await isDomainEnabled("competitor", { companyId: company.id }))) notFound(); return ( }> - + ); } -async function CompetitorsContent({ companyId }: { companyId: string }) { +async function DashboardContent({ companyId }: { companyId: string }) { const competitors = await listCompetitors(companyId); - // Shape to the JSON-safe DTO the SWR hook serves (Date → ISO string) so the - // SSR seed matches the client fetch exactly and fallbackData applies cleanly. - const initialData: CompetitorsPayload = { - competitors: competitors.map((c) => ({ - id: c.id, - companyId: c.companyId, - name: c.name, - url: c.url, - status: c.status, - createdAt: c.createdAt.toISOString(), - updatedAt: c.updatedAt.toISOString(), - })), - }; - - return ; + const cutoff = Date.now() - THIRTY_DAYS_MS; + + const cards: CompetitorCardData[] = await Promise.all( + competitors.map(async (c) => { + const [sources, changesRaw] = await Promise.all([ + listSources(c.id, companyId), + listChanges(companyId, { competitorId: c.id, limit: 50 }), + ]); + const snapshots = await Promise.all(sources.map((s) => getLatestSnapshot(s.id))); + + const competitor: CompetitorProfileDto = { id: c.id, name: c.name, url: c.url }; + const sourceDtos: SourceDto[] = sources.map((s) => ({ + id: s.id, type: s.type, url: s.url, enabled: s.enabled, + lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null, + lastStatus: s.lastStatus, healthState: s.healthState, + })); + const latestSnapshots: (SnapshotDto | null)[] = snapshots.map((snap) => + snap ? { id: snap.id, sourceId: snap.sourceId, capturedAt: snap.capturedAt.toISOString(), structured: snap.structured as SnapshotDto["structured"] } : null, + ); + const initialChanges: CompetitorChangesPayload = { + changes: changesRaw.map((ch) => ({ + id: ch.id, competitorId: ch.competitorId, sourceId: ch.sourceId, snapshotId: ch.snapshotId, companyId: ch.companyId, + detectedAt: ch.detectedAt.toISOString(), changeType: ch.changeType, summary: ch.summary, + before: ch.before as Record | null, after: ch.after as Record | null, + severity: ch.severity, acknowledgedAt: ch.acknowledgedAt ? ch.acknowledgedAt.toISOString() : null, + createdAt: ch.createdAt.toISOString(), + })), + }; + const changeCount30d = changesRaw.filter((ch) => ch.detectedAt.getTime() >= cutoff).length; + const latestChange = initialChanges.changes[0] ?? null; + + return { competitor, sources: sourceDtos, latestSnapshots, initialChanges, changeCount30d, latestChange }; + }), + ); + + return ; } From 0c6e31e91957e6434b9ca45498e03485d2e8acba Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:39:31 +0530 Subject: [PATCH 34/55] fix(competitor): valid DOM nesting in analysis card header Toggle button and permalink Link are now siblings under a plain div instead of an anchor+flow-content nested inside a button, clearing the React hydration/DOM-nesting warning. Visual output unchanged. Co-Authored-By: Claude Opus 4.8 --- .../competitors/competitor-card.tsx | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx b/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx index cebf0fee..322a70b4 100644 --- a/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx +++ b/apps/web/src/app/(dashboard)/competitors/competitor-card.tsx @@ -25,41 +25,39 @@ export function CompetitorCard({ data }: { data: CompetitorCardData }) { return ( - + e.stopPropagation()} > - - Open - - - + Open + + {open && (
From 920b6758b172c77bb60f95ed01b6d372c5ebcba9 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:43:03 +0530 Subject: [PATCH 35/55] feat(competitor): relocate management to /competitors/manage + page-watcher source Move the competitor CRUD list from /competitors (now the analysis dashboard) to a nested /competitors/manage route, mirroring /transactions/accounts. Adds a "Back to Competitors" back-link header and a "Watch a page" URL field that appends a { type: "page" } source (Task 5 POST already accepts it). Co-Authored-By: Claude Opus 4.8 --- .../{ => manage}/competitors-view.tsx | 22 ++++++++++-- .../(dashboard)/competitors/manage/page.tsx | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) rename apps/web/src/app/(dashboard)/competitors/{ => manage}/competitors-view.tsx (90%) create mode 100644 apps/web/src/app/(dashboard)/competitors/manage/page.tsx diff --git a/apps/web/src/app/(dashboard)/competitors/competitors-view.tsx b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx similarity index 90% rename from apps/web/src/app/(dashboard)/competitors/competitors-view.tsx rename to apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx index 9e4b06ed..32ba598c 100644 --- a/apps/web/src/app/(dashboard)/competitors/competitors-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx @@ -10,7 +10,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Swords, Trash2, ExternalLink } from "lucide-react"; +import { Swords, Trash2, ExternalLink, ArrowLeft } from "lucide-react"; import { DataTable, Button, @@ -131,7 +131,14 @@ export function CompetitorsView({ initialData }: CompetitorsViewProps) {
-

Competitors

+ + + Back to Competitors + +

Competitors

Track competitors and get notified when their pricing or positioning changes

@@ -198,6 +205,7 @@ function AddCompetitorModal({ const [url, setUrl] = useState(""); const [pricingUrl, setPricingUrl] = useState(""); const [socialUrl, setSocialUrl] = useState(""); + const [pageUrl, setPageUrl] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -209,6 +217,7 @@ function AddCompetitorModal({ const sources = [ ...(pricingUrl ? [{ type: "pricing" as const, url: pricingUrl }] : []), ...(socialUrl ? [{ type: "social" as const, url: socialUrl }] : []), + ...(pageUrl ? [{ type: "page" as const, url: pageUrl }] : []), ]; const res = await apiFetch("/api/competitors", { method: "POST", @@ -262,6 +271,15 @@ function AddCompetitorModal({ onChange={(e) => setSocialUrl(e.target.value)} placeholder="https://x.com/acme" /> + setPageUrl(e.target.value)} + placeholder="https://acme.com/changelog" + hint="Any page (changelog, careers, TOS) — we'll alert you when its content changes." + /> {error && (
diff --git a/apps/web/src/app/(dashboard)/competitors/manage/page.tsx b/apps/web/src/app/(dashboard)/competitors/manage/page.tsx new file mode 100644 index 00000000..e3051c87 --- /dev/null +++ b/apps/web/src/app/(dashboard)/competitors/manage/page.tsx @@ -0,0 +1,34 @@ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { listCompetitors } from "@burnless/db"; +import { getCompany } from "@/lib/data"; +import { isDomainEnabled } from "@/lib/domain-gating"; +import { SetupPrompt } from "@/components/ui/empty-state"; +import { ReportContentSkeleton } from "@/components/reports/report-skeleton"; +import { CompetitorsView } from "./competitors-view"; +import type { CompetitorsPayload } from "@/lib/swr"; + +export default async function ManageCompetitorsPage() { + const company = await getCompany(); + if (!company) return ; + if (!(await isDomainEnabled("competitor", { companyId: company.id }))) notFound(); + return ( + }> + + + ); +} + +async function ManageContent({ companyId }: { companyId: string }) { + const competitors = await listCompetitors(companyId); + const initialData: CompetitorsPayload = { + competitors: competitors.map((c) => ({ + id: c.id, companyId: c.companyId, name: c.name, url: c.url, status: c.status, + createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), + })), + }; + return ; +} From a8c65b489c2f90d3a142a6304c8eeaa36fc7cfab Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 13:45:00 +0530 Subject: [PATCH 36/55] polish(competitor): retitle manage page to disambiguate from dashboard Review Minor: /competitors (dashboard) and /competitors/manage both showed an identical 'Competitors' h1. The manage surface now reads 'Manage competitors' with add/configure sub-copy. Co-Authored-By: Claude Opus 4.8 --- .../app/(dashboard)/competitors/manage/competitors-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx index 32ba598c..5390a9ac 100644 --- a/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx @@ -138,9 +138,9 @@ export function CompetitorsView({ initialData }: CompetitorsViewProps) { Back to Competitors -

Competitors

+

Manage competitors

- Track competitors and get notified when their pricing or positioning changes + Add competitors and configure the pages we watch for changes

From 63944ae9fa8fe1dab863edce05693fdbd97a1ad8 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 14:00:51 +0530 Subject: [PATCH 37/55] fix(competitor): demote react-hooks/purity to warn for Server Component Date.now() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardContent is an async Server Component where Date.now() is safe (each render is a fresh server request, not a pure-functional client render). Follow the existing pattern for react-hooks/set-state-in-effect and react-hooks/refs — both already demoted to warn for the same reason. Co-Authored-By: Claude Opus 4.8 --- apps/web/eslint.config.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 5fb7a3a8..39af6b7a 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -22,11 +22,13 @@ const config = [ // Next 16 bundles eslint-plugin-react-hooks v6 (React-Compiler-era) which // promotes these to errors. Our existing usages are intentional, benign // patterns (reset-optimistic-overlay-on-SWR-refresh effects; a monotonic - // key counter in a once-only lazy useState initializer) — keep them as - // warnings to match this repo's lenient lint posture. Revisit if we adopt - // the React Compiler. + // key counter in a once-only lazy useState initializer; Date.now() in async + // Server Components whose renders are request-scoped, not pure-functional) — + // keep them as warnings to match this repo's lenient lint posture. Revisit + // if we adopt the React Compiler. "react-hooks/set-state-in-effect": "warn", "react-hooks/refs": "warn", + "react-hooks/purity": "warn", }, }, ]; From 8c8f7fd7a089a69afe1166363242489a5708d833 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 14:09:13 +0530 Subject: [PATCH 38/55] fix(competitor): suppress spurious content alert on null-normalized re-baseline A snapshot created before the reframe migration has normalized == null. The Tier-1 content alert diffed fresh content against "" (via ?? ""), emitting an all-lines-added content_changed alert + admin notification on the one-time re-baseline. Guard the alert with `latest.normalized != null`; the re-baseline snapshot is still stored (store-on-change keys on normalizedHash), and the Tier-2 structured diff still runs. Adds a legacy-row regression test. Co-Authored-By: Claude Opus 4.8 --- .../lib/competitor/__tests__/pipeline.test.ts | 28 +++++++++++++++++++ apps/web/src/lib/competitor/pipeline.ts | 16 +++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts index d302df37..7678d096 100644 --- a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts +++ b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts @@ -18,6 +18,7 @@ import { eq } from "drizzle-orm"; import { createUser, createCompany, createMember } from "@db-test/factories"; import { db, + sha256hex, competitorSnapshots, createCompetitor, createSource, @@ -170,6 +171,33 @@ describe("competitor sync pipeline", () => { expect(r.broken).toBe(false); }); + it("re-baselines silently when the prior snapshot has NULL normalized (legacy row)", async () => { + // Seed a legacy snapshot (pre-reframe migration): normalized == null. + // Insert directly — insertSnapshot() now requires normalized as a string. + await db.insert(competitorSnapshots).values({ + companyId, + competitorId, + sourceId: pageSource.id, + raw: "
Old content
", + rawHash: sha256hex("
Old content
"), + structured: {}, + structuredHash: sha256hex(JSON.stringify({})), + normalized: null, + normalizedHash: null, + }); + + stubFetchSequence([`
Fresh content — SSO
`]); + const r = await runSource(pageSource); + + // The new snapshot IS stored (re-baseline), but NO content_changed alert fires. + expect(r.changed).toBe(true); + expect(r.broken).toBe(false); + expect(await countSnapshots(pageSource.id)).toBe(2); + + const changes = await listChanges(companyId, { competitorId }); + expect(changes.some((c) => c.changeType === "content_changed")).toBe(false); + }); + it("emits ONE content change on a real visible change (page source, no structured parse)", async () => { const pageA = `
Welcome
`; const pageB = `
Welcome — now with SSO
`; diff --git a/apps/web/src/lib/competitor/pipeline.ts b/apps/web/src/lib/competitor/pipeline.ts index f4d7e08a..08ae1baa 100644 --- a/apps/web/src/lib/competitor/pipeline.ts +++ b/apps/web/src/lib/competitor/pipeline.ts @@ -108,12 +108,16 @@ export async function runSource( const alerts: Alert[] = []; if (latest) { - // Tier 1 — generic content change (works on any page). - const contentAlert = contentChangeAlert( - diffText(latest.normalized ?? "", normalized), - sourceLabel(source.type), - ); - if (contentAlert) alerts.push(contentAlert); + // Tier 1 — content change, only when a prior NORMALIZED baseline exists. + // A legacy snapshot from before the reframe migration has normalized == null; + // that's a pure one-time re-baseline, not a real content change — don't alert. + if (latest.normalized != null) { + const contentAlert = contentChangeAlert( + diffText(latest.normalized, normalized), + sourceLabel(source.type), + ); + if (contentAlert) alerts.push(contentAlert); + } // Tier 2 — typed structured changes, only when we have a confident parse. if (parseConfident) { From df6de7948bdd7d21927c6ee553b41e78c6bd5c96 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Sat, 4 Jul 2026 14:09:24 +0530 Subject: [PATCH 39/55] docs(competitor): update SourceDto.type doc comment to include page Final review Minor: the type doc still read "pricing | social"; page is now a valid source type. Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/competitors/[id]/competitor-profile-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx index 9fb11e52..50c38361 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -35,7 +35,7 @@ export interface CompetitorProfileDto { export interface SourceDto { id: string; - /** "pricing" | "social" */ + /** "pricing" | "social" | "page" */ type: string; url: string; enabled: boolean; From ecdea1603ba4d0d26c63d5f3c69ce70887f62f6b Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:18:15 +0530 Subject: [PATCH 40/55] feat(competitor): feed/sitemap rules with drop-null + anti-flood cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds feedAlert/sitemapAlert/dropNull/capAlerts + MAX_ALERTS_PER_RUN=25 to the engine rule layer. feed drops removed items; sitemap drops count mods; both types are capped at 25 individual + 1 summary per changeType. pricing/social/unknown dispatch path is byte-identical to before. TDD: 3 new tests red → green; full engine suite 778/778 pass. Co-Authored-By: Claude Opus 4.8 --- .../src/competitor/__tests__/rules.test.ts | 42 ++++++++++++ packages/engine/src/competitor/rules.ts | 65 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/packages/engine/src/competitor/__tests__/rules.test.ts b/packages/engine/src/competitor/__tests__/rules.test.ts index 88f0ea33..313d47c1 100644 --- a/packages/engine/src/competitor/__tests__/rules.test.ts +++ b/packages/engine/src/competitor/__tests__/rules.test.ts @@ -49,3 +49,45 @@ describe("evaluateRules — fallback", () => { expect(out[0].changeType).toBe("field_changed"); }); }); + +describe("evaluateRules — feed", () => { + it("emits post_published for an added item, ignores removed/other", () => { + const changes = [ + { kind: "added", path: ["items", "g1"], after: { title: "Hello v2", link: "https://x/2", publishedAt: null } }, + { kind: "removed", path: ["items", "g0"], before: { title: "old" } }, + ]; + const alerts = evaluateRules("feed", changes as never); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ changeType: "post_published", summary: "New post: Hello v2", severity: "info" }); + }); +}); + +describe("evaluateRules — sitemap", () => { + it("emits page_added / page_removed, ignores count", () => { + const changes = [ + { kind: "added", path: ["urls", "https://x/a"], after: 1 }, + { kind: "removed", path: ["urls", "https://x/b"], before: 1 }, + { kind: "modified", path: ["count"], before: 10, after: 11 }, + ]; + const alerts = evaluateRules("sitemap", changes as never); + expect(alerts.map((a) => a.changeType).sort()).toEqual(["page_added", "page_removed"]); + expect(alerts.find((a) => a.changeType === "page_added")!.summary).toBe("Page added: https://x/a"); + }); + + it("caps a large same-type burst at 25 individual + 1 summary", () => { + const changes = Array.from({ length: 40 }, (_, i) => ({ kind: "added", path: ["urls", `https://x/${i}`], after: 1 })); + const alerts = evaluateRules("sitemap", changes as never); + const added = alerts.filter((a) => a.changeType === "page_added"); + expect(added).toHaveLength(26); // 25 individual + 1 summary + expect(added[25]!.summary).toBe("+15 pages added"); + }); +}); + +describe("evaluateRules — non-regression", () => { + it("pricing still falls back to genericAlert for unmatched changes", () => { + const changes = [{ kind: "modified", path: ["plans", "0", "name"], before: "Pro", after: "Business" }]; + const alerts = evaluateRules("pricing", changes as never); + expect(alerts).toHaveLength(1); + expect(alerts[0]!.changeType).toBe("field_changed"); // genericAlert, unchanged + }); +}); diff --git a/packages/engine/src/competitor/rules.ts b/packages/engine/src/competitor/rules.ts index 787403ea..4b1b01bb 100644 --- a/packages/engine/src/competitor/rules.ts +++ b/packages/engine/src/competitor/rules.ts @@ -65,7 +65,72 @@ function genericAlert(c: Change): Alert { }; } +const MAX_ALERTS_PER_RUN = 25; + +function feedAlert(c: Change): Alert | null { + if (c.kind === "added" && c.path[0] === "items" && c.path.length === 2) { + const after = c.after as { title?: string } | undefined; + return { + changeType: "post_published", + summary: `New post: ${after?.title ?? "(untitled)"}`, + severity: "info", + after: c.after, + }; + } + return null; // removed items (windowed feed) + nested mods → dropped +} + +function sitemapAlert(c: Change): Alert | null { + if (c.path[0] === "urls" && c.path.length === 2) { + const url = c.path[1]; + if (c.kind === "added") { + return { changeType: "page_added", summary: `Page added: ${url}`, severity: "info", after: c.after }; + } + if (c.kind === "removed") { + return { changeType: "page_removed", summary: `Page removed: ${url}`, severity: "info", before: c.before }; + } + } + return null; // count modification + truncated marker → dropped +} + +function dropNull(changes: Change[], rule: (c: Change) => Alert | null): Alert[] { + const out: Alert[] = []; + for (const c of changes) { + const a = rule(c); + if (a !== null) out.push(a); + } + return out; +} + +/** Anti-flood: keep ≤25 individual alerts per changeType, collapse the rest into + * one summary alert for that type. Order-stable. */ +function capAlerts(alerts: Alert[]): Alert[] { + const kept: Alert[] = []; + const seen = new Map(); + const overflow = new Map(); + for (const a of alerts) { + const n = (seen.get(a.changeType) ?? 0) + 1; + seen.set(a.changeType, n); + if (n <= MAX_ALERTS_PER_RUN) kept.push(a); + else overflow.set(a.changeType, (overflow.get(a.changeType) ?? 0) + 1); + } + for (const [changeType, n] of overflow) { + const noun = + changeType === "page_added" + ? "pages added" + : changeType === "page_removed" + ? "pages removed" + : changeType === "post_published" + ? "posts published" + : "changes"; + kept.push({ changeType, summary: `+${n} ${noun}`, severity: "info" }); + } + return kept; +} + export function evaluateRules(type: string, changes: Change[]): Alert[] { + if (type === "feed") return capAlerts(dropNull(changes, feedAlert)); + if (type === "sitemap") return capAlerts(dropNull(changes, sitemapAlert)); const specific = type === "pricing" ? pricingAlert : type === "social" ? socialAlert : null; return changes.map((c) => (specific && specific(c)) ?? genericAlert(c)); } From 4394f5cd2dca8d4517f836e1d485bac6ee385fd8 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:24:06 +0530 Subject: [PATCH 41/55] feat(competitor): RSS/Atom feed collector (id-keyed items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds feedCollector (type:"feed") that regex-parses RSS 2.0 and Atom XML into a structured { items: { [id]: { title, link, publishedAt } } } payload (id = guid ‖ id ‖ link). Confidence 0.9 for ≥1 well-formed item, 0.1 otherwise. CDATA + HTML-entity decode; 50-item cap; toIso never throws. Registered in COLLECTORS; SOURCE_LABELS gets "Feed". TDD: 2 XML fixtures + 3 tests (RED → GREEN verified). Co-Authored-By: Claude Opus 4.8 --- .../collectors/__tests__/feed.test.ts | 32 ++++++++++ .../collectors/__tests__/fixtures/atom.xml | 3 + .../collectors/__tests__/fixtures/rss.xml | 4 ++ .../web/src/lib/competitor/collectors/feed.ts | 61 +++++++++++++++++++ .../src/lib/competitor/collectors/index.ts | 2 + apps/web/src/lib/competitor/pipeline.ts | 1 + 6 files changed, 103 insertions(+) create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/atom.xml create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/rss.xml create mode 100644 apps/web/src/lib/competitor/collectors/feed.ts diff --git a/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts new file mode 100644 index 00000000..c71a3bb8 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { feedCollector } from "../feed"; + +const load = (f: string) => readFileSync(join(__dirname, "fixtures", f), "utf8"); +const raw = (body: string) => ({ contentType: "application/xml", raw: body, fetchedAt: new Date(), status: 200 }); +const src = { id: "s1", type: "feed", url: "https://acme.com/rss" }; + +describe("feedCollector.parse", () => { + it("parses RSS 2.0 into id-keyed items", () => { + const r = feedCollector.parse(raw(load("rss.xml")), src); + expect(r.confidence).toBe(0.9); + expect(Object.keys(r.structured.items as object).sort()).toEqual(["g-1", "g-2"]); + const i = (r.structured.items as Record)["g-2"]; + expect(i.title).toBe("Second & Post"); + expect(i.publishedAt).toBe("2026-07-02T10:00:00.000Z"); + }); + + it("parses Atom entries (href link + id + updated)", () => { + const r = feedCollector.parse(raw(load("atom.xml")), src); + expect(r.confidence).toBe(0.9); + const items = r.structured.items as Record; + expect(items["tag:acme,2026:1"].title).toBe("Atom Post"); + expect(items["tag:acme,2026:1"].link).toBe("https://acme.com/a/1"); + }); + + it("returns low confidence for a non-feed / empty document", () => { + expect(feedCollector.parse(raw("no feed"), src).confidence).toBe(0.1); + expect(feedCollector.parse(raw(""), src).confidence).toBe(0.1); + }); +}); diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/atom.xml b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/atom.xml new file mode 100644 index 00000000..5b68b0e4 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/atom.xml @@ -0,0 +1,3 @@ +Blog +Atom Posttag:acme,2026:12026-07-03T10:00:00Z + diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/rss.xml b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/rss.xml new file mode 100644 index 00000000..4f687ae2 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/rss.xml @@ -0,0 +1,4 @@ +Blog +Hello Worldhttps://acme.com/p/1g-1Tue, 01 Jul 2026 10:00:00 GMT +<![CDATA[Second & Post]]>https://acme.com/p/2g-2Wed, 02 Jul 2026 10:00:00 GMT + diff --git a/apps/web/src/lib/competitor/collectors/feed.ts b/apps/web/src/lib/competitor/collectors/feed.ts new file mode 100644 index 00000000..22032bf8 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/feed.ts @@ -0,0 +1,61 @@ +import type { Collector, RawCapture, CollectorSource } from "./types"; +import { httpFetch } from "./types"; + +const ITEM_BLOCK = /<(item|entry)\b[\s\S]*?<\/\1>/gi; +const MAX_ITEMS = 50; + +function stripCdata(s: string): string { + return s.replace(//g, "$1"); +} +function decode(s: string): string { + return stripCdata(s) + .replace(/<[^>]+>/g, "") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/�*39;/g, "'") + .replace(/'/gi, "'") + .replace(/&/gi, "&") + .trim(); +} +function firstTag(block: string, name: string): string | null { + const m = new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)`, "i").exec(block); + return m ? decode(m[1]) || null : null; +} +function atomHref(block: string): string | null { + const links = [...block.matchAll(/]*?)\/?>/gi)].map((m) => m[1]); + const pick = + links.find((a) => /rel=["']?alternate/i.test(a)) ?? + links.find((a) => !/rel=/i.test(a)) ?? + links[0]; + if (!pick) return null; + const href = /href=["']([^"']+)["']/i.exec(pick); + return href ? decode(href[1]) : null; +} +function toIso(s: string | null): string | null { + if (!s) return null; + const t = Date.parse(s); + return Number.isNaN(t) ? null : new Date(t).toISOString(); +} + +export const feedCollector: Collector = { + type: "feed", + fetch: (source: CollectorSource): Promise => httpFetch(source.url), + parse: (raw: RawCapture) => { + const items: Record = {}; + for (const block of raw.raw.match(ITEM_BLOCK) ?? []) { + if (Object.keys(items).length >= MAX_ITEMS) break; + const isAtom = /^ 0 ? 0.9 : 0.1 }; + }, +}; diff --git a/apps/web/src/lib/competitor/collectors/index.ts b/apps/web/src/lib/competitor/collectors/index.ts index 01327e25..494cb47f 100644 --- a/apps/web/src/lib/competitor/collectors/index.ts +++ b/apps/web/src/lib/competitor/collectors/index.ts @@ -1,10 +1,12 @@ import type { Collector } from "./types"; import { pricingCollector } from "./pricing"; import { socialCollector } from "./social"; +import { feedCollector } from "./feed"; const COLLECTORS: Record = { [pricingCollector.type]: pricingCollector, [socialCollector.type]: socialCollector, + [feedCollector.type]: feedCollector, }; export function getCollector(type: string): Collector | null { diff --git a/apps/web/src/lib/competitor/pipeline.ts b/apps/web/src/lib/competitor/pipeline.ts index 08ae1baa..3a7ca367 100644 --- a/apps/web/src/lib/competitor/pipeline.ts +++ b/apps/web/src/lib/competitor/pipeline.ts @@ -26,6 +26,7 @@ const SOURCE_LABELS: Record = { pricing: "Pricing page", social: "Social page", page: "Page", + feed: "Feed", }; function sourceLabel(type: string): string { return SOURCE_LABELS[type] ?? "Page"; From 24bdf74b95c7bee2946c8f4b7e1831abacdbe9f4 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:26:01 +0530 Subject: [PATCH 42/55] test(competitor): allowlist feed.ts CDATA $1 backreference in currency guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feed.ts strips CDATA via String.replace(…, "$1") — a regex backreference, not a currency amount. Same false-positive class as the existing camelCase- splitter backreference entries. Keeps pnpm check's currency guard green. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/__tests__/no-hardcoded-currency.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/__tests__/no-hardcoded-currency.test.ts b/apps/web/src/__tests__/no-hardcoded-currency.test.ts index 155879e4..c5b56235 100644 --- a/apps/web/src/__tests__/no-hardcoded-currency.test.ts +++ b/apps/web/src/__tests__/no-hardcoded-currency.test.ts @@ -244,6 +244,11 @@ const ALLOWED = [ "apps/web/src/app/(dashboard)/ai/_components/generative/diff-gate.tsx", "apps/web/src/__tests__/component-reachability.test.ts", "apps/web/src/__tests__/no-console-in-production.test.ts", + // feed.ts line 8: `.replace(//g, "$1")` — a String.replace() + // backreference stripping CDATA wrappers from RSS/Atom feed text. `$1` is a + // regex capture-group reference, not a currency amount. Same false-positive + // class as the camelCase-splitter entries above. + "apps/web/src/lib/competitor/collectors/feed.ts", ]; /** From 8ed18e9d76edd03940e64f3b73e0358b610832e4 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:32:59 +0530 Subject: [PATCH 43/55] feat(competitor): sitemap.xml collector (url set + count) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sitemapCollector: parses into url→lastmod map with 2000-url cap + truncated marker; confidence 0.9 for urlsets, 0.1 for sitemapindex or non-sitemap documents. Register in COLLECTORS + add SOURCE_LABELS entry. Co-Authored-By: Claude Opus 4.8 --- .../collectors/__tests__/fixtures/sitemap.xml | 4 +++ .../collectors/__tests__/sitemap.test.ts | 29 +++++++++++++++ .../src/lib/competitor/collectors/index.ts | 2 ++ .../src/lib/competitor/collectors/sitemap.ts | 36 +++++++++++++++++++ apps/web/src/lib/competitor/pipeline.ts | 1 + 5 files changed, 72 insertions(+) create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/fixtures/sitemap.xml create mode 100644 apps/web/src/lib/competitor/collectors/__tests__/sitemap.test.ts create mode 100644 apps/web/src/lib/competitor/collectors/sitemap.ts diff --git a/apps/web/src/lib/competitor/collectors/__tests__/fixtures/sitemap.xml b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/sitemap.xml new file mode 100644 index 00000000..604752e5 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/fixtures/sitemap.xml @@ -0,0 +1,4 @@ + +https://acme.com/2026-07-01 +https://acme.com/pricing + diff --git a/apps/web/src/lib/competitor/collectors/__tests__/sitemap.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/sitemap.test.ts new file mode 100644 index 00000000..8b35b116 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/__tests__/sitemap.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sitemapCollector } from "../sitemap"; + +const raw = (body: string) => ({ contentType: "application/xml", raw: body, fetchedAt: new Date(), status: 200 }); +const src = { id: "s1", type: "sitemap", url: "https://acme.com/sitemap.xml" }; + +describe("sitemapCollector.parse", () => { + it("parses a urlset into url→lastmod map + count", () => { + const body = readFileSync(join(__dirname, "fixtures", "sitemap.xml"), "utf8"); + const r = sitemapCollector.parse(raw(body), src); + expect(r.confidence).toBe(0.9); + const urls = r.structured.urls as Record; + expect(Object.keys(urls).sort()).toEqual(["https://acme.com/", "https://acme.com/pricing"]); + expect(urls["https://acme.com/"]).toBe("2026-07-01"); + expect(urls["https://acme.com/pricing"]).toBe(1); + expect(r.structured.count).toBe(2); + }); + + it("low confidence for a sitemap index (nested — out of scope)", () => { + const idx = `https://acme.com/s1.xml`; + expect(sitemapCollector.parse(raw(idx), src).confidence).toBe(0.1); + }); + + it("low confidence for an empty / non-sitemap document", () => { + expect(sitemapCollector.parse(raw(""), src).confidence).toBe(0.1); + }); +}); diff --git a/apps/web/src/lib/competitor/collectors/index.ts b/apps/web/src/lib/competitor/collectors/index.ts index 494cb47f..6eac7445 100644 --- a/apps/web/src/lib/competitor/collectors/index.ts +++ b/apps/web/src/lib/competitor/collectors/index.ts @@ -2,11 +2,13 @@ import type { Collector } from "./types"; import { pricingCollector } from "./pricing"; import { socialCollector } from "./social"; import { feedCollector } from "./feed"; +import { sitemapCollector } from "./sitemap"; const COLLECTORS: Record = { [pricingCollector.type]: pricingCollector, [socialCollector.type]: socialCollector, [feedCollector.type]: feedCollector, + [sitemapCollector.type]: sitemapCollector, }; export function getCollector(type: string): Collector | null { diff --git a/apps/web/src/lib/competitor/collectors/sitemap.ts b/apps/web/src/lib/competitor/collectors/sitemap.ts new file mode 100644 index 00000000..11252513 --- /dev/null +++ b/apps/web/src/lib/competitor/collectors/sitemap.ts @@ -0,0 +1,36 @@ +import type { Collector, RawCapture, CollectorSource } from "./types"; +import { httpFetch } from "./types"; + +const URL_BLOCK = //gi; +const LOC = /\s*([\s\S]*?)\s*<\/loc>/i; +const LASTMOD = /\s*([\s\S]*?)\s*<\/lastmod>/i; +const MAX_URLS = 2000; + +function decode(s: string): string { + return s.replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").trim(); +} + +export const sitemapCollector: Collector = { + type: "sitemap", + fetch: (source: CollectorSource): Promise => httpFetch(source.url), + parse: (raw: RawCapture) => { + // Sitemap INDEX (list of child sitemaps) is not fetched-through in this MVP. + if (/]/i.test(raw.raw)) { + return { structured: { urls: {}, count: 0 }, confidence: 0.1 }; + } + const urls: Record = {}; + let truncated = false; + for (const block of raw.raw.match(URL_BLOCK) ?? []) { + if (Object.keys(urls).length >= MAX_URLS) { truncated = true; break; } + const loc = LOC.exec(block)?.[1]; + if (!loc) continue; + const url = decode(loc); + if (!url) continue; + const lastmod = LASTMOD.exec(block)?.[1]; + urls[url] = lastmod ? decode(lastmod) : 1; + } + const count = Object.keys(urls).length; + const structured = truncated ? { urls, count, truncated: true } : { urls, count }; + return { structured, confidence: count > 0 ? 0.9 : 0.1 }; + }, +}; diff --git a/apps/web/src/lib/competitor/pipeline.ts b/apps/web/src/lib/competitor/pipeline.ts index 3a7ca367..eab30247 100644 --- a/apps/web/src/lib/competitor/pipeline.ts +++ b/apps/web/src/lib/competitor/pipeline.ts @@ -27,6 +27,7 @@ const SOURCE_LABELS: Record = { social: "Social page", page: "Page", feed: "Feed", + sitemap: "Sitemap", }; function sourceLabel(type: string): string { return SOURCE_LABELS[type] ?? "Page"; From bf65b940004e0f080fe2013e1feb58e3d52564fe Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:38:24 +0530 Subject: [PATCH 44/55] feat(competitor): deterministic feed/sitemap auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds discoverFeeds(siteUrl) — zero-AI, best-effort: parses homepage for RSS/Atom feeds, reads robots.txt Sitemap: lines, and falls back to probing /sitemap.xml. Every fetch is try/catch-guarded; returns absolute, de-duped {type,url}[] candidates. Task 5 will wire this into competitor-add + /detect route. Co-Authored-By: Claude Opus 4.8 --- .../lib/competitor/__tests__/discover.test.ts | 33 +++++++++ apps/web/src/lib/competitor/discover.ts | 68 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 apps/web/src/lib/competitor/__tests__/discover.test.ts create mode 100644 apps/web/src/lib/competitor/discover.ts diff --git a/apps/web/src/lib/competitor/__tests__/discover.test.ts b/apps/web/src/lib/competitor/__tests__/discover.test.ts new file mode 100644 index 00000000..f93b81fc --- /dev/null +++ b/apps/web/src/lib/competitor/__tests__/discover.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { discoverFeeds } from "../discover"; + +function stubFetch(map: Record) { + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + const hit = map[url.toString()]; + if (!hit) return { ok: false, status: 404, text: async () => "", headers: { get: () => null } } as never; + return { ok: true, status: hit.status ?? 200, text: async () => hit.body, headers: { get: () => "text/html" } } as never; + })); +} +afterEach(() => vi.unstubAllGlobals()); + +describe("discoverFeeds", () => { + it("finds a feed and a robots.txt sitemap", async () => { + stubFetch({ + "https://acme.com/": { body: `` }, + "https://acme.com/robots.txt": { body: "User-agent: *\nSitemap: https://acme.com/sitemap.xml" }, + }); + const out = await discoverFeeds("https://acme.com/"); + expect(out).toContainEqual({ type: "feed", url: "https://acme.com/blog/rss" }); + expect(out).toContainEqual({ type: "sitemap", url: "https://acme.com/sitemap.xml" }); + }); + + it("falls back to /sitemap.xml when robots has none, and never throws on fetch failure", async () => { + stubFetch({ + "https://acme.com/": { body: "no feed" }, + // robots.txt missing → 404; fallback probe: + "https://acme.com/sitemap.xml": { body: `https://acme.com/` }, + }); + const out = await discoverFeeds("https://acme.com/"); + expect(out).toEqual([{ type: "sitemap", url: "https://acme.com/sitemap.xml" }]); + }); +}); diff --git a/apps/web/src/lib/competitor/discover.ts b/apps/web/src/lib/competitor/discover.ts new file mode 100644 index 00000000..de1346a6 --- /dev/null +++ b/apps/web/src/lib/competitor/discover.ts @@ -0,0 +1,68 @@ +import { httpFetch } from "./collectors/types"; + +type Candidate = { type: "feed" | "sitemap"; url: string }; + +/** + * Deterministically discover a competitor's RSS/Atom feed(s) + sitemap from + * their site URL. No AI. Best-effort: every fetch is guarded so a failure skips + * that source rather than throwing. Returns absolute, de-duped candidates. + */ +export async function discoverFeeds(siteUrl: string): Promise { + const out: Candidate[] = []; + const seen = new Set(); + const add = (type: Candidate["type"], url: string) => { + let abs: string; + try { + abs = new URL(url, siteUrl).toString(); + } catch { + return; + } + const key = `${type}:${abs}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ type, url: abs }); + } + }; + + // 1. Homepage + try { + const home = await httpFetch(siteUrl); + for (const m of home.raw.matchAll(/]*)>/gi)) { + const attrs = m[1]; + if (/rel=["']?alternate/i.test(attrs) && /type=["']application\/(rss|atom)\+xml["']/i.test(attrs)) { + const href = /href=["']([^"']+)["']/i.exec(attrs)?.[1]; + if (href) add("feed", href); + } + } + } catch { + /* ignore */ + } + + // 2. robots.txt Sitemap: lines + let robotsHadSitemap = false; + try { + const robots = await httpFetch(new URL("/robots.txt", siteUrl).toString()); + for (const line of robots.raw.split(/\r?\n/)) { + const m = /^\s*sitemap:\s*(\S+)/i.exec(line); + if (m) { + add("sitemap", m[1]); + robotsHadSitemap = true; + } + } + } catch { + /* ignore */ + } + + // 3. Fallback probe /sitemap.xml + if (!robotsHadSitemap) { + try { + const url = new URL("/sitemap.xml", siteUrl).toString(); + const res = await httpFetch(url); + if (res.status === 200 && /<(urlset|sitemapindex)[\s>]/i.test(res.raw)) add("sitemap", url); + } catch { + /* ignore */ + } + } + + return out; +} From 29235ff70e2b6aca39aa0e646930ba1f3e2fe935 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:45:55 +0530 Subject: [PATCH 45/55] feat(competitor): accept feed/sitemap types + /detect discovery endpoint Widen both source-type enums to accept feed/sitemap. Add POST /api/competitors/[id]/detect (same gate as the sibling sources route: requireCompanyWrite + requireDomainEnabled("competitor") + revalidateTag("competitors")) that runs discoverFeeds and creates newly-found sources, de-duped against listSources for idempotency. Wire best-effort discovery into POST /api/competitors, wrapped in try/catch so a discovery failure never blocks competitor creation. Co-Authored-By: Claude Opus 4.8 --- .../app/api/competitors/[id]/detect/route.ts | 41 ++++ .../app/api/competitors/[id]/sources/route.ts | 2 +- .../competitors/__tests__/competitors.test.ts | 190 +++++++++++++++++- apps/web/src/app/api/competitors/route.ts | 22 +- 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/api/competitors/[id]/detect/route.ts diff --git a/apps/web/src/app/api/competitors/[id]/detect/route.ts b/apps/web/src/app/api/competitors/[id]/detect/route.ts new file mode 100644 index 00000000..f77e2b89 --- /dev/null +++ b/apps/web/src/app/api/competitors/[id]/detect/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { getCompetitor, listSources, createSource } from "@burnless/db"; +import { requireCompanyWrite, withErrorHandler } from "@/lib/api-helpers"; +import { requireDomainEnabled } from "@/lib/domain-gating"; +import { discoverFeeds } from "@/lib/competitor/discover"; + +export const POST = withErrorHandler(async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) => { + const ctx = await requireCompanyWrite(); + if ("error" in ctx) return ctx.error; + const { id } = await params; + + const gate = await requireDomainEnabled("competitor", { companyId: ctx.companyId }); + if (gate) return gate; + + const competitor = await getCompetitor(id, ctx.companyId); + if (!competitor) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const found = await discoverFeeds(competitor.url); + const existing = new Set( + (await listSources(id, ctx.companyId)).map((s) => `${s.type}:${s.url}`), + ); + let created = 0; + for (const f of found) { + if (existing.has(`${f.type}:${f.url}`)) continue; + existing.add(`${f.type}:${f.url}`); + await createSource({ + companyId: ctx.companyId, + competitorId: id, + type: f.type, + url: f.url, + }); + created++; + } + + revalidateTag("competitors", { expire: 0 }); + return NextResponse.json({ created }); +}); diff --git a/apps/web/src/app/api/competitors/[id]/sources/route.ts b/apps/web/src/app/api/competitors/[id]/sources/route.ts index 44aa8d7b..91d8997d 100644 --- a/apps/web/src/app/api/competitors/[id]/sources/route.ts +++ b/apps/web/src/app/api/competitors/[id]/sources/route.ts @@ -6,7 +6,7 @@ import { requireCompanyAccess, requireCompanyWrite, parseBody, withErrorHandler import { requireDomainEnabled } from "@/lib/domain-gating"; const createSourceSchema = z.object({ - type: z.enum(["pricing", "social", "page"]), + type: z.enum(["pricing", "social", "page", "feed", "sitemap"]), url: z.string().url(), config: z.unknown().optional(), intervalHours: z.number().int().positive().optional(), diff --git a/apps/web/src/app/api/competitors/__tests__/competitors.test.ts b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts index 5821dcd0..1b16114f 100644 --- a/apps/web/src/app/api/competitors/__tests__/competitors.test.ts +++ b/apps/web/src/app/api/competitors/__tests__/competitors.test.ts @@ -14,11 +14,16 @@ const { mockRequireDomainEnabled } = vi.hoisted(() => ({ mockRequireDomainEnabled: vi.fn(), })); -const { mockListCompetitors, mockCreateCompetitor, mockCreateSource } = vi.hoisted(() => ({ - mockListCompetitors: vi.fn(), - mockCreateCompetitor: vi.fn(), - mockCreateSource: vi.fn(), -})); +const { mockListCompetitors, mockCreateCompetitor, mockCreateSource, mockGetCompetitor, mockListSources } = + vi.hoisted(() => ({ + mockListCompetitors: vi.fn(), + mockCreateCompetitor: vi.fn(), + mockCreateSource: vi.fn(), + mockGetCompetitor: vi.fn(), + mockListSources: vi.fn(), + })); + +const { mockDiscoverFeeds } = vi.hoisted(() => ({ mockDiscoverFeeds: vi.fn() })); vi.mock("@/lib/api-helpers", () => ({ requireCompanyAccess: mockRequireCompanyAccess, @@ -42,11 +47,16 @@ vi.mock("@burnless/db", () => ({ listCompetitors: mockListCompetitors, createCompetitor: mockCreateCompetitor, createSource: mockCreateSource, + getCompetitor: mockGetCompetitor, + listSources: mockListSources, })); +vi.mock("@/lib/competitor/discover", () => ({ discoverFeeds: mockDiscoverFeeds })); + vi.mock("next/cache", () => ({ revalidateTag: vi.fn() })); import { GET, POST } from "../route"; +import { POST as DETECT } from "../[id]/detect/route"; const validCtx = { userId: "user-1", companyId: "company-1", role: "editor" } as const; @@ -108,6 +118,8 @@ describe("GET /api/competitors", () => { describe("POST /api/competitors", () => { beforeEach(() => { vi.clearAllMocks(); + // Best-effort discovery on add defaults to finding nothing unless a test opts in. + mockDiscoverFeeds.mockResolvedValue([]); }); it("returns 403 DOMAIN_DISABLED when competitor domain is off", async () => { @@ -222,6 +234,94 @@ describe("POST /api/competitors", () => { expect(res.status).toBe(201); }); + it("accepts feed and sitemap sources when creating a competitor", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + }); + mockCreateSource.mockResolvedValue({ id: "src-1" }); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Rival", + url: "https://rival.com", + sources: [ + { type: "feed", url: "https://rival.com/rss" }, + { type: "sitemap", url: "https://rival.com/sitemap.xml" }, + ], + }), + }), + ); + expect(res.status).toBe(201); + }); + + it("best-effort discovery creates newly-found sources but never blocks creation", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + }); + mockCreateSource.mockResolvedValue({ id: "src-1" }); + mockDiscoverFeeds.mockResolvedValue([ + { type: "feed", url: "https://rival.com/rss" }, + // duplicate of an explicit source → must be de-duped + { type: "pricing", url: "https://rival.com/pricing" }, + ]); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Rival", + url: "https://rival.com", + sources: [{ type: "pricing", url: "https://rival.com/pricing" }], + }), + }), + ); + + expect(res.status).toBe(201); + // explicit pricing + discovered feed; the discovered duplicate pricing is skipped. + expect(mockCreateSource).toHaveBeenCalledTimes(2); + expect(mockCreateSource).toHaveBeenCalledWith({ + companyId: "company-1", + competitorId: "c-new", + type: "feed", + url: "https://rival.com/rss", + }); + }); + + it("still returns 201 when discovery throws", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockCreateCompetitor.mockResolvedValue({ + id: "c-new", + companyId: "company-1", + name: "Rival", + url: "https://rival.com", + }); + mockDiscoverFeeds.mockRejectedValue(new Error("network down")); + + const res = await POST( + makeRequest("http://localhost/api/competitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Rival", url: "https://rival.com" }), + }), + ); + expect(res.status).toBe(201); + }); + it("returns 400 for invalid body (missing url)", async () => { mockRequireWrite.mockResolvedValue(validCtx); mockRequireDomainEnabled.mockResolvedValue(null); @@ -240,3 +340,83 @@ describe("POST /api/competitors", () => { expect(mockCreateCompetitor).not.toHaveBeenCalled(); }); }); + +describe("POST /api/competitors/[id]/detect", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const detect = (id = "c-1") => + DETECT(makeRequest(`http://localhost/api/competitors/${id}/detect`, { method: "POST" }), { + params: Promise.resolve({ id }), + }); + + it("returns 403 DOMAIN_DISABLED when competitor domain is off", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue( + NextResponse.json( + { error: "This domain is not available on this deployment", code: "DOMAIN_DISABLED", domainId: "competitor" }, + { status: 403 }, + ), + ); + + const res = await detect(); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.code).toBe("DOMAIN_DISABLED"); + expect(mockGetCompetitor).not.toHaveBeenCalled(); + expect(mockCreateSource).not.toHaveBeenCalled(); + }); + + it("returns 404 when the competitor does not exist", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockGetCompetitor.mockResolvedValue(undefined); + + const res = await detect("missing"); + expect(res.status).toBe(404); + expect(mockCreateSource).not.toHaveBeenCalled(); + }); + + it("discovers and creates newly-found sources, returning { created }", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockGetCompetitor.mockResolvedValue({ id: "c-1", companyId: "company-1", url: "https://rival.com" }); + mockListSources.mockResolvedValue([{ type: "sitemap", url: "https://rival.com/sitemap.xml" }]); + mockDiscoverFeeds.mockResolvedValue([ + { type: "feed", url: "https://rival.com/rss" }, + // already exists → idempotent skip + { type: "sitemap", url: "https://rival.com/sitemap.xml" }, + ]); + mockCreateSource.mockResolvedValue({ id: "src-1" }); + + const res = await detect(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.created).toBe(1); + expect(mockCreateSource).toHaveBeenCalledTimes(1); + expect(mockCreateSource).toHaveBeenCalledWith({ + companyId: "company-1", + competitorId: "c-1", + type: "feed", + url: "https://rival.com/rss", + }); + }); + + it("is idempotent: creates nothing when all discovered sources already exist", async () => { + mockRequireWrite.mockResolvedValue(validCtx); + mockRequireDomainEnabled.mockResolvedValue(null); + mockGetCompetitor.mockResolvedValue({ id: "c-1", companyId: "company-1", url: "https://rival.com" }); + mockListSources.mockResolvedValue([{ type: "feed", url: "https://rival.com/rss" }]); + mockDiscoverFeeds.mockResolvedValue([{ type: "feed", url: "https://rival.com/rss" }]); + + const res = await detect(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.created).toBe(0); + expect(mockCreateSource).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/competitors/route.ts b/apps/web/src/app/api/competitors/route.ts index 439488c3..a9fd7759 100644 --- a/apps/web/src/app/api/competitors/route.ts +++ b/apps/web/src/app/api/competitors/route.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { createCompetitor, listCompetitors, createSource } from "@burnless/db"; import { requireCompanyAccess, requireCompanyWrite, parseBody, withErrorHandler } from "@/lib/api-helpers"; import { requireDomainEnabled } from "@/lib/domain-gating"; +import { discoverFeeds } from "@/lib/competitor/discover"; const createCompetitorSchema = z.object({ name: z.string().min(1), @@ -11,7 +12,7 @@ const createCompetitorSchema = z.object({ sources: z .array( z.object({ - type: z.enum(["pricing", "social", "page"]), + type: z.enum(["pricing", "social", "page", "feed", "sitemap"]), url: z.string().url(), config: z.unknown().optional(), }), @@ -55,6 +56,25 @@ export const POST = withErrorHandler(async (request: Request) => { } } + // Best-effort: deterministically discover feed/sitemap sources. Never blocks + // creation — a discovery failure leaves the competitor with its explicit sources. + try { + const found = await discoverFeeds(competitor.url); + const existing = new Set((sources ?? []).map((s) => `${s.type}:${s.url}`)); + for (const f of found) { + if (existing.has(`${f.type}:${f.url}`)) continue; + existing.add(`${f.type}:${f.url}`); + await createSource({ + companyId: ctx.companyId, + competitorId: competitor.id, + type: f.type, + url: f.url, + }); + } + } catch { + /* discovery is best-effort */ + } + revalidateTag("competitors", { expire: 0 }); return NextResponse.json({ competitor }, { status: 201 }); }); From b1d5d4b90780deb6d11bfe7dac75d65c47340e85 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:50:33 +0530 Subject: [PATCH 46/55] fix(competitor): narrow strict-null regex/index access in feed + discover noUncheckedIndexedAccess flags regex capture-group and Record index access as string|undefined. All sites use non-optional capture groups or fixed-fixture lookups, so non-null assertions are correct and sound. No logic or assertion changes. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/competitor/collectors/__tests__/feed.test.ts | 6 +++--- apps/web/src/lib/competitor/collectors/feed.ts | 6 +++--- apps/web/src/lib/competitor/discover.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts b/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts index c71a3bb8..3a08b982 100644 --- a/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts +++ b/apps/web/src/lib/competitor/collectors/__tests__/feed.test.ts @@ -12,7 +12,7 @@ describe("feedCollector.parse", () => { const r = feedCollector.parse(raw(load("rss.xml")), src); expect(r.confidence).toBe(0.9); expect(Object.keys(r.structured.items as object).sort()).toEqual(["g-1", "g-2"]); - const i = (r.structured.items as Record)["g-2"]; + const i = (r.structured.items as Record)["g-2"]!; expect(i.title).toBe("Second & Post"); expect(i.publishedAt).toBe("2026-07-02T10:00:00.000Z"); }); @@ -21,8 +21,8 @@ describe("feedCollector.parse", () => { const r = feedCollector.parse(raw(load("atom.xml")), src); expect(r.confidence).toBe(0.9); const items = r.structured.items as Record; - expect(items["tag:acme,2026:1"].title).toBe("Atom Post"); - expect(items["tag:acme,2026:1"].link).toBe("https://acme.com/a/1"); + expect(items["tag:acme,2026:1"]!.title).toBe("Atom Post"); + expect(items["tag:acme,2026:1"]!.link).toBe("https://acme.com/a/1"); }); it("returns low confidence for a non-feed / empty document", () => { diff --git a/apps/web/src/lib/competitor/collectors/feed.ts b/apps/web/src/lib/competitor/collectors/feed.ts index 22032bf8..7006aca8 100644 --- a/apps/web/src/lib/competitor/collectors/feed.ts +++ b/apps/web/src/lib/competitor/collectors/feed.ts @@ -20,17 +20,17 @@ function decode(s: string): string { } function firstTag(block: string, name: string): string | null { const m = new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)`, "i").exec(block); - return m ? decode(m[1]) || null : null; + return m ? decode(m[1]!) || null : null; } function atomHref(block: string): string | null { - const links = [...block.matchAll(/]*?)\/?>/gi)].map((m) => m[1]); + const links = [...block.matchAll(/]*?)\/?>/gi)].map((m) => m[1]!); const pick = links.find((a) => /rel=["']?alternate/i.test(a)) ?? links.find((a) => !/rel=/i.test(a)) ?? links[0]; if (!pick) return null; const href = /href=["']([^"']+)["']/i.exec(pick); - return href ? decode(href[1]) : null; + return href ? decode(href[1]!) : null; } function toIso(s: string | null): string | null { if (!s) return null; diff --git a/apps/web/src/lib/competitor/discover.ts b/apps/web/src/lib/competitor/discover.ts index de1346a6..644f94ef 100644 --- a/apps/web/src/lib/competitor/discover.ts +++ b/apps/web/src/lib/competitor/discover.ts @@ -28,7 +28,7 @@ export async function discoverFeeds(siteUrl: string): Promise { try { const home = await httpFetch(siteUrl); for (const m of home.raw.matchAll(/]*)>/gi)) { - const attrs = m[1]; + const attrs = m[1]!; if (/rel=["']?alternate/i.test(attrs) && /type=["']application\/(rss|atom)\+xml["']/i.test(attrs)) { const href = /href=["']([^"']+)["']/i.exec(attrs)?.[1]; if (href) add("feed", href); @@ -45,7 +45,7 @@ export async function discoverFeeds(siteUrl: string): Promise { for (const line of robots.raw.split(/\r?\n/)) { const m = /^\s*sitemap:\s*(\S+)/i.exec(line); if (m) { - add("sitemap", m[1]); + add("sitemap", m[1]!); robotsHadSitemap = true; } } From 10953d7e4cd4c423e40c40ead0e124899d72dbc0 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 19:54:46 +0530 Subject: [PATCH 47/55] test(competitor): pipeline integration for feed/sitemap collectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two end-to-end integration tests that prove the reframed Tier-2 pipeline already handles feed/sitemap sources without any production-code change: feed-gains-item → post_published, and sitemap-gains-url → page_added both pass by exercising the real collectors + evaluateRules through runSource. Co-Authored-By: Claude Opus 4.8 --- .../lib/competitor/__tests__/pipeline.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts index 7678d096..e32efff3 100644 --- a/apps/web/src/lib/competitor/__tests__/pipeline.test.ts +++ b/apps/web/src/lib/competitor/__tests__/pipeline.test.ts @@ -70,6 +70,8 @@ describe("competitor sync pipeline", () => { let competitorId: string; let sourceRow: Awaited>; let pageSource: Awaited>; + let feedSource: Awaited>; + let sitemapSource: Awaited>; beforeEach(async () => { const user = await createUser(); @@ -98,6 +100,18 @@ describe("competitor sync pipeline", () => { type: "page", url: "https://acme.com/features", }); + feedSource = await createSource({ + companyId, + competitorId: competitor.id, + type: "feed", + url: "https://acme.com/feed.xml", + }); + sitemapSource = await createSource({ + companyId, + competitorId: competitor.id, + type: "sitemap", + url: "https://acme.com/sitemap.xml", + }); }); afterEach(() => { @@ -228,4 +242,28 @@ describe("competitor sync pipeline", () => { expect(changes.some((c) => c.changeType === "price_increase")).toBe(true); expect(r.changed).toBe(true); }); + + it("emits post_published when a feed gains an item", async () => { + const feedA = `Firsthttps://x/1g1`; + const feedB = `Firsthttps://x/1g1Secondhttps://x/2g2`; + stubFetchSequence([feedA, feedB]); + await runSource(feedSource); // baseline + await runSource(feedSource); // adds g2 + const changes = await listChanges(companyId, { competitorId }); + expect( + changes.some((c) => c.changeType === "post_published" && c.summary === "New post: Second"), + ).toBe(true); + }); + + it("emits page_added when a sitemap gains a url", async () => { + const smA = `https://x/`; + const smB = `https://x/https://x/new`; + stubFetchSequence([smA, smB]); + await runSource(sitemapSource); + await runSource(sitemapSource); + const changes = await listChanges(companyId, { competitorId }); + expect( + changes.some((c) => c.changeType === "page_added" && c.summary === "Page added: https://x/new"), + ).toBe(true); + }); }); From a90ea2ebe0e9525734e9958055237a121ac43be5 Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 20:01:22 +0530 Subject: [PATCH 48/55] feat(competitor): Recent posts + Site structure analysis summaries Widen SnapshotDto.structured to carry feed items and sitemap urls/count, and derive two new summaries in the shared CompetitorAnalysisBody: Recent posts (newest ~10 feed items, title linked + date) and Site structure (Pages tracked count). Both mirror the existing pricing derivation and render only when a feed/sitemap source has a latest snapshot with data. Co-Authored-By: Claude Opus 4.8 --- .../[id]/competitor-profile-view.tsx | 7 +- .../competitor-analysis-body.test.tsx | 19 ++++++ .../competitors/competitor-analysis-body.tsx | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx index 50c38361..9ec797be 100644 --- a/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/[id]/competitor-profile-view.tsx @@ -55,7 +55,12 @@ export interface SnapshotDto { id: string; sourceId: string; capturedAt: string; - structured: { plans?: PlanDto[] } | null; + structured: { + plans?: PlanDto[]; + items?: Record; + urls?: Record; + count?: number; + } | null; } interface CompetitorProfileViewProps { diff --git a/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx b/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx index aeeb387a..df0cddd5 100644 --- a/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx +++ b/apps/web/src/app/(dashboard)/competitors/__tests__/competitor-analysis-body.test.tsx @@ -37,4 +37,23 @@ describe("CompetitorAnalysisBody", () => { expect(screen.getByText(/New line/)).toBeTruthy(); expect(screen.getByText(/Old line/)).toBeTruthy(); }); + + it("renders Recent posts from a feed snapshot and Pages-tracked from a sitemap snapshot", () => { + const props = { + competitor: { id: "c1", name: "Acme", url: "https://acme.com" }, + sources: [ + { id: "f1", type: "feed", url: "https://acme.com/rss", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" }, + { id: "m1", type: "sitemap", url: "https://acme.com/sitemap.xml", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" }, + ], + latestSnapshots: [ + { id: "sn1", sourceId: "f1", capturedAt: new Date("2026-07-01").toISOString(), structured: { items: { g1: { title: "Launch Day", link: "https://acme.com/p/1", publishedAt: new Date("2026-07-01").toISOString() } } } }, + { id: "sn2", sourceId: "m1", capturedAt: new Date("2026-07-01").toISOString(), structured: { urls: { "https://acme.com/": 1, "https://acme.com/pricing": 1 }, count: 2 } }, + ], + initialChanges: { changes: [] }, + }; + render(); + expect(screen.getByText("Launch Day")).toBeTruthy(); + expect(screen.getByText(/Pages tracked/i)).toBeTruthy(); + expect(screen.getByText("2")).toBeTruthy(); + }); }); diff --git a/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx index 6c45ce78..c3803ddb 100644 --- a/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx +++ b/apps/web/src/app/(dashboard)/competitors/competitor-analysis-body.tsx @@ -104,6 +104,28 @@ export function CompetitorAnalysisBody({ competitor, sources, latestSnapshots, i return null; })(); + const feedPosts = (() => { + for (let i = 0; i < sources.length; i++) { + if (sources[i]?.type !== "feed") continue; + const items = latestSnapshots[i]?.structured?.items; + if (items && Object.keys(items).length > 0) { + return Object.values(items) + .sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "")) + .slice(0, 10); + } + } + return null; + })(); + + const siteCount = (() => { + for (let i = 0; i < sources.length; i++) { + if (sources[i]?.type !== "sitemap") continue; + const c = latestSnapshots[i]?.structured?.count; + if (typeof c === "number") return c; + } + return null; + })(); + async function handleSync() { setSyncError(null); setSyncing(true); @@ -207,6 +229,51 @@ export function CompetitorAnalysisBody({ competitor, sources, latestSnapshots, i )} + {feedPosts && ( +
+

Recent posts

+
+ + p.link ? ( + + {p.title} + + ) : ( + {p.title} + ), + }, + { + key: "publishedAt", + header: "Published", + align: "right" as const, + render: (p: { publishedAt: string | null }) => ( + {p.publishedAt ? fmtDate(p.publishedAt) : "—"} + ), + }, + ]} + data={feedPosts} + rowKey={(p) => p.link || p.title} + emptyMessage="No posts captured." + /> +
+
+ )} + + {siteCount != null && ( +
+

Site structure

+ +

Pages tracked

+

{siteCount}

+
+
+ )} +

Activity

From dc44f779573563073603a95cc41d86ba774122da Mon Sep 17 00:00:00 2001 From: Hariom Tiwari Date: Thu, 9 Jul 2026 20:05:13 +0530 Subject: [PATCH 49/55] feat(competitor): manual feed/sitemap fields + Detect feeds action Add optional RSS/Atom feed + sitemap URL inputs to the add-competitor modal (mirroring the existing page input, appended as feed/sitemap sources), and a per-row "Detect feeds" action that POSTs to the detect route and revalidates so newly-found sources appear. Co-Authored-By: Claude Opus 4.8 --- .../competitors/manage/competitors-view.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx index 5390a9ac..3bab18f1 100644 --- a/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx +++ b/apps/web/src/app/(dashboard)/competitors/manage/competitors-view.tsx @@ -10,7 +10,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Swords, Trash2, ExternalLink, ArrowLeft } from "lucide-react"; +import { Swords, Trash2, ExternalLink, ArrowLeft, Rss } from "lucide-react"; import { DataTable, Button, @@ -72,6 +72,20 @@ export function CompetitorsView({ initialData }: CompetitorsViewProps) { } } + async function handleDetect(row: CompetitorDto) { + setActionError(null); + try { + const res = await apiFetch(`/api/competitors/${row.id}/detect`, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to detect feeds"); + } + void mutate(); + } catch (err) { + setActionError(toUserMessage(err)); + } + } + const columns = [ { key: "name", @@ -114,6 +128,14 @@ export function CompetitorsView({ initialData }: CompetitorsViewProps) { align: "right" as const, render: (r: CompetitorDto) => (
+