From 3c18d64ede669beee539e8c4272dacb05f9a22ef Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:51:03 -0500 Subject: [PATCH 01/42] feat: [US-002] Create Supabase functions for artist_composio_connections - Add artist_composio_connections type to database.types.ts - Create selectArtistComposioConnection.ts (single lookup by artist+toolkit) - Create selectArtistComposioConnections.ts (all connections for artist) - Create insertArtistComposioConnection.ts (upsert on unique constraint) - Create deleteArtistComposioConnection.ts (delete by id) Co-Authored-By: Claude Opus 4.5 --- .../deleteArtistComposioConnection.ts | 20 + .../insertArtistComposioConnection.ts | 27 + .../selectArtistComposioConnection.ts | 27 + .../selectArtistComposioConnections.ts | 23 + types/database.types.ts | 6778 +++++++++-------- 5 files changed, 3493 insertions(+), 3382 deletions(-) create mode 100644 lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts create mode 100644 lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts create mode 100644 lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts create mode 100644 lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts diff --git a/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts new file mode 100644 index 00000000..3ed705ac --- /dev/null +++ b/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts @@ -0,0 +1,20 @@ +import supabase from "../serverClient"; + +/** + * Deletes an artist_composio_connection by its ID. + * + * @param connectionId - The connection's unique ID + * @returns Object with error property (null if successful) + */ +export async function deleteArtistComposioConnection(connectionId: string) { + const { error } = await supabase + .from("artist_composio_connections") + .delete() + .eq("id", connectionId); + + if (error) { + return { error }; + } + + return { error: null }; +} diff --git a/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts new file mode 100644 index 00000000..cfeffaea --- /dev/null +++ b/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts @@ -0,0 +1,27 @@ +import supabase from "../serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts or updates (upserts) an artist_composio_connection. + * Uses the unique constraint on (artist_id, toolkit_slug) for conflict resolution. + * + * @param connection - The connection data to insert + * @returns The upserted connection row + */ +export async function insertArtistComposioConnection( + connection: TablesInsert<"artist_composio_connections">, +): Promise<{ data: Tables<"artist_composio_connections"> | null; error: unknown }> { + const { data, error } = await supabase + .from("artist_composio_connections") + .upsert(connection, { + onConflict: "artist_id,toolkit_slug", + }) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} diff --git a/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts new file mode 100644 index 00000000..39173ed1 --- /dev/null +++ b/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts @@ -0,0 +1,27 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects a single artist_composio_connection by artist_id and toolkit_slug. + * + * @param artistId - The artist's ID (from account_info) + * @param toolkitSlug - The toolkit identifier (e.g., 'tiktok') + * @returns The connection row or null if not found + */ +export async function selectArtistComposioConnection( + artistId: string, + toolkitSlug: string, +): Promise | null> { + const { data, error } = await supabase + .from("artist_composio_connections") + .select("*") + .eq("artist_id", artistId) + .eq("toolkit_slug", toolkitSlug) + .single(); + + if (error || !data) { + return null; + } + + return data; +} diff --git a/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts b/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts new file mode 100644 index 00000000..bbff6354 --- /dev/null +++ b/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts @@ -0,0 +1,23 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects all artist_composio_connections for a given artist. + * + * @param artistId - The artist's ID (from account_info) + * @returns Array of connection rows, or empty array if none found + */ +export async function selectArtistComposioConnections( + artistId: string, +): Promise[]> { + const { data, error } = await supabase + .from("artist_composio_connections") + .select("*") + .eq("artist_id", artistId); + + if (error || !data) { + return []; + } + + return data; +} diff --git a/types/database.types.ts b/types/database.types.ts index 9ce9509c..d4cfa237 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -1,3814 +1,3843 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "12.2.3 (519615d)" - } + PostgrestVersion: "12.2.3 (519615d)"; + }; public: { Tables: { account_api_keys: { Row: { - account: string | null - created_at: string - id: string - key_hash: string | null - last_used: string | null - name: string - } - Insert: { - account?: string | null - created_at?: string - id?: string - key_hash?: string | null - last_used?: string | null - name: string - } - Update: { - account?: string | null - created_at?: string - id?: string - key_hash?: string | null - last_used?: string | null - name?: string - } + account: string | null; + created_at: string; + id: string; + key_hash: string | null; + last_used: string | null; + name: string; + }; + Insert: { + account?: string | null; + created_at?: string; + id?: string; + key_hash?: string | null; + last_used?: string | null; + name: string; + }; + Update: { + account?: string | null; + created_at?: string; + id?: string; + key_hash?: string | null; + last_used?: string | null; + name?: string; + }; Relationships: [ { - foreignKeyName: "account_api_keys_account_fkey" - columns: ["account"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_api_keys_account_fkey"; + columns: ["account"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_artist_ids: { Row: { - account_id: string | null - artist_id: string | null - id: string - pinned: boolean - updated_at: string | null - } - Insert: { - account_id?: string | null - artist_id?: string | null - id?: string - pinned?: boolean - updated_at?: string | null - } - Update: { - account_id?: string | null - artist_id?: string | null - id?: string - pinned?: boolean - updated_at?: string | null - } + account_id: string | null; + artist_id: string | null; + id: string; + pinned: boolean; + updated_at: string | null; + }; + Insert: { + account_id?: string | null; + artist_id?: string | null; + id?: string; + pinned?: boolean; + updated_at?: string | null; + }; + Update: { + account_id?: string | null; + artist_id?: string | null; + id?: string; + pinned?: boolean; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "account_artist_ids_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_artist_ids_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_artist_ids_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_artist_ids_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_catalogs: { Row: { - account: string - catalog: string - created_at: string - id: string - updated_at: string - } - Insert: { - account: string - catalog: string - created_at?: string - id?: string - updated_at?: string - } - Update: { - account?: string - catalog?: string - created_at?: string - id?: string - updated_at?: string - } + account: string; + catalog: string; + created_at: string; + id: string; + updated_at: string; + }; + Insert: { + account: string; + catalog: string; + created_at?: string; + id?: string; + updated_at?: string; + }; + Update: { + account?: string; + catalog?: string; + created_at?: string; + id?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "account_catalogs_account_fkey" - columns: ["account"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_catalogs_account_fkey"; + columns: ["account"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_catalogs_catalog_fkey" - columns: ["catalog"] - isOneToOne: false - referencedRelation: "catalogs" - referencedColumns: ["id"] + foreignKeyName: "account_catalogs_catalog_fkey"; + columns: ["catalog"]; + isOneToOne: false; + referencedRelation: "catalogs"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_emails: { Row: { - account_id: string | null - email: string | null - id: string - updated_at: string - } - Insert: { - account_id?: string | null - email?: string | null - id?: string - updated_at?: string - } - Update: { - account_id?: string | null - email?: string | null - id?: string - updated_at?: string - } + account_id: string | null; + email: string | null; + id: string; + updated_at: string; + }; + Insert: { + account_id?: string | null; + email?: string | null; + id?: string; + updated_at?: string; + }; + Update: { + account_id?: string | null; + email?: string | null; + id?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "account_emails_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_emails_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_info: { Row: { - account_id: string | null - company_name: string | null - id: string - image: string | null - instruction: string | null - job_title: string | null - knowledges: Json | null - label: string | null - onboarding_data: Json | null - onboarding_status: Json | null - organization: string | null - role_type: string | null - updated_at: string - } - Insert: { - account_id?: string | null - company_name?: string | null - id?: string - image?: string | null - instruction?: string | null - job_title?: string | null - knowledges?: Json | null - label?: string | null - onboarding_data?: Json | null - onboarding_status?: Json | null - organization?: string | null - role_type?: string | null - updated_at?: string - } - Update: { - account_id?: string | null - company_name?: string | null - id?: string - image?: string | null - instruction?: string | null - job_title?: string | null - knowledges?: Json | null - label?: string | null - onboarding_data?: Json | null - onboarding_status?: Json | null - organization?: string | null - role_type?: string | null - updated_at?: string - } + account_id: string | null; + company_name: string | null; + id: string; + image: string | null; + instruction: string | null; + job_title: string | null; + knowledges: Json | null; + label: string | null; + onboarding_data: Json | null; + onboarding_status: Json | null; + organization: string | null; + role_type: string | null; + updated_at: string; + }; + Insert: { + account_id?: string | null; + company_name?: string | null; + id?: string; + image?: string | null; + instruction?: string | null; + job_title?: string | null; + knowledges?: Json | null; + label?: string | null; + onboarding_data?: Json | null; + onboarding_status?: Json | null; + organization?: string | null; + role_type?: string | null; + updated_at?: string; + }; + Update: { + account_id?: string | null; + company_name?: string | null; + id?: string; + image?: string | null; + instruction?: string | null; + job_title?: string | null; + knowledges?: Json | null; + label?: string | null; + onboarding_data?: Json | null; + onboarding_status?: Json | null; + organization?: string | null; + role_type?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "account_info_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_info_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_organization_ids: { Row: { - account_id: string | null - id: string - organization_id: string | null - updated_at: string | null - } - Insert: { - account_id?: string | null - id?: string - organization_id?: string | null - updated_at?: string | null - } - Update: { - account_id?: string | null - id?: string - organization_id?: string | null - updated_at?: string | null - } + account_id: string | null; + id: string; + organization_id: string | null; + updated_at: string | null; + }; + Insert: { + account_id?: string | null; + id?: string; + organization_id?: string | null; + updated_at?: string | null; + }; + Update: { + account_id?: string | null; + id?: string; + organization_id?: string | null; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "account_organization_ids_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_organization_ids_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_organization_ids_organization_id_fkey" - columns: ["organization_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_organization_ids_organization_id_fkey"; + columns: ["organization_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_phone_numbers: { Row: { - account_id: string - id: string - phone_number: string - updated_at: string | null - } - Insert: { - account_id: string - id?: string - phone_number: string - updated_at?: string | null - } - Update: { - account_id?: string - id?: string - phone_number?: string - updated_at?: string | null - } + account_id: string; + id: string; + phone_number: string; + updated_at: string | null; + }; + Insert: { + account_id: string; + id?: string; + phone_number: string; + updated_at?: string | null; + }; + Update: { + account_id?: string; + id?: string; + phone_number?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "account_phone_numbers_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_phone_numbers_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_socials: { Row: { - account_id: string | null - id: string - social_id: string - } + account_id: string | null; + id: string; + social_id: string; + }; Insert: { - account_id?: string | null - id?: string - social_id?: string - } + account_id?: string | null; + id?: string; + social_id?: string; + }; Update: { - account_id?: string | null - id?: string - social_id?: string - } + account_id?: string | null; + id?: string; + social_id?: string; + }; Relationships: [ { - foreignKeyName: "account_socials_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_socials_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_socials_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "account_socials_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_wallets: { Row: { - account_id: string - id: string - updated_at: string | null - wallet: string - } - Insert: { - account_id: string - id?: string - updated_at?: string | null - wallet: string - } - Update: { - account_id?: string - id?: string - updated_at?: string | null - wallet?: string - } + account_id: string; + id: string; + updated_at: string | null; + wallet: string; + }; + Insert: { + account_id: string; + id?: string; + updated_at?: string | null; + wallet: string; + }; + Update: { + account_id?: string; + id?: string; + updated_at?: string | null; + wallet?: string; + }; Relationships: [ { - foreignKeyName: "account_wallets_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_wallets_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; account_workspace_ids: { Row: { - account_id: string | null - id: string - updated_at: string | null - workspace_id: string | null - } - Insert: { - account_id?: string | null - id?: string - updated_at?: string | null - workspace_id?: string | null - } - Update: { - account_id?: string | null - id?: string - updated_at?: string | null - workspace_id?: string | null - } + account_id: string | null; + id: string; + updated_at: string | null; + workspace_id: string | null; + }; + Insert: { + account_id?: string | null; + id?: string; + updated_at?: string | null; + workspace_id?: string | null; + }; + Update: { + account_id?: string | null; + id?: string; + updated_at?: string | null; + workspace_id?: string | null; + }; Relationships: [ { - foreignKeyName: "account_workspace_ids_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_workspace_ids_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_workspace_ids_workspace_id_fkey" - columns: ["workspace_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_workspace_ids_workspace_id_fkey"; + columns: ["workspace_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; accounts: { Row: { - id: string - name: string | null - timestamp: number | null - } - Insert: { - id?: string - name?: string | null - timestamp?: number | null - } - Update: { - id?: string - name?: string | null - timestamp?: number | null - } - Relationships: [] - } + id: string; + name: string | null; + timestamp: number | null; + }; + Insert: { + id?: string; + name?: string | null; + timestamp?: number | null; + }; + Update: { + id?: string; + name?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; accounts_memberships: { Row: { - account_id: string - account_role: string - created_at: string - created_by: string | null - updated_at: string - updated_by: string | null - user_id: string - } - Insert: { - account_id: string - account_role: string - created_at?: string - created_by?: string | null - updated_at?: string - updated_by?: string | null - user_id: string - } - Update: { - account_id?: string - account_role?: string - created_at?: string - created_by?: string | null - updated_at?: string - updated_by?: string | null - user_id?: string - } + account_id: string; + account_role: string; + created_at: string; + created_by: string | null; + updated_at: string; + updated_by: string | null; + user_id: string; + }; + Insert: { + account_id: string; + account_role: string; + created_at?: string; + created_by?: string | null; + updated_at?: string; + updated_by?: string | null; + user_id: string; + }; + Update: { + account_id?: string; + account_role?: string; + created_at?: string; + created_by?: string | null; + updated_at?: string; + updated_by?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "accounts_memberships_account_role_fkey" - columns: ["account_role"] - isOneToOne: false - referencedRelation: "roles" - referencedColumns: ["name"] + foreignKeyName: "accounts_memberships_account_role_fkey"; + columns: ["account_role"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["name"]; }, - ] - } + ]; + }; admin_expenses: { Row: { - amount: number - category: string - created_at: string | null - created_by: string | null - id: string - is_active: boolean | null - item_name: string - updated_at: string | null - } - Insert: { - amount?: number - category: string - created_at?: string | null - created_by?: string | null - id?: string - is_active?: boolean | null - item_name: string - updated_at?: string | null - } - Update: { - amount?: number - category?: string - created_at?: string | null - created_by?: string | null - id?: string - is_active?: boolean | null - item_name?: string - updated_at?: string | null - } - Relationships: [] - } + amount: number; + category: string; + created_at: string | null; + created_by: string | null; + id: string; + is_active: boolean | null; + item_name: string; + updated_at: string | null; + }; + Insert: { + amount?: number; + category: string; + created_at?: string | null; + created_by?: string | null; + id?: string; + is_active?: boolean | null; + item_name: string; + updated_at?: string | null; + }; + Update: { + amount?: number; + category?: string; + created_at?: string | null; + created_by?: string | null; + id?: string; + is_active?: boolean | null; + item_name?: string; + updated_at?: string | null; + }; + Relationships: []; + }; admin_user_profiles: { Row: { - company: string | null - context_notes: string | null - created_at: string | null - email: string - id: string - job_title: string | null - last_contact_date: string | null - meeting_notes: string | null - observations: string | null - opportunities: string | null - pain_points: string | null - sentiment: string | null - tags: string[] | null - updated_at: string | null - } - Insert: { - company?: string | null - context_notes?: string | null - created_at?: string | null - email: string - id?: string - job_title?: string | null - last_contact_date?: string | null - meeting_notes?: string | null - observations?: string | null - opportunities?: string | null - pain_points?: string | null - sentiment?: string | null - tags?: string[] | null - updated_at?: string | null - } - Update: { - company?: string | null - context_notes?: string | null - created_at?: string | null - email?: string - id?: string - job_title?: string | null - last_contact_date?: string | null - meeting_notes?: string | null - observations?: string | null - opportunities?: string | null - pain_points?: string | null - sentiment?: string | null - tags?: string[] | null - updated_at?: string | null - } - Relationships: [] - } + company: string | null; + context_notes: string | null; + created_at: string | null; + email: string; + id: string; + job_title: string | null; + last_contact_date: string | null; + meeting_notes: string | null; + observations: string | null; + opportunities: string | null; + pain_points: string | null; + sentiment: string | null; + tags: string[] | null; + updated_at: string | null; + }; + Insert: { + company?: string | null; + context_notes?: string | null; + created_at?: string | null; + email: string; + id?: string; + job_title?: string | null; + last_contact_date?: string | null; + meeting_notes?: string | null; + observations?: string | null; + opportunities?: string | null; + pain_points?: string | null; + sentiment?: string | null; + tags?: string[] | null; + updated_at?: string | null; + }; + Update: { + company?: string | null; + context_notes?: string | null; + created_at?: string | null; + email?: string; + id?: string; + job_title?: string | null; + last_contact_date?: string | null; + meeting_notes?: string | null; + observations?: string | null; + opportunities?: string | null; + pain_points?: string | null; + sentiment?: string | null; + tags?: string[] | null; + updated_at?: string | null; + }; + Relationships: []; + }; agent_status: { Row: { - agent_id: string - id: string - progress: number | null - social_id: string - status: number | null - updated_at: string - } - Insert: { - agent_id?: string - id?: string - progress?: number | null - social_id: string - status?: number | null - updated_at?: string - } - Update: { - agent_id?: string - id?: string - progress?: number | null - social_id?: string - status?: number | null - updated_at?: string - } + agent_id: string; + id: string; + progress: number | null; + social_id: string; + status: number | null; + updated_at: string; + }; + Insert: { + agent_id?: string; + id?: string; + progress?: number | null; + social_id: string; + status?: number | null; + updated_at?: string; + }; + Update: { + agent_id?: string; + id?: string; + progress?: number | null; + social_id?: string; + status?: number | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "agent_status_agent_id_fkey" - columns: ["agent_id"] - isOneToOne: false - referencedRelation: "agents" - referencedColumns: ["id"] + foreignKeyName: "agent_status_agent_id_fkey"; + columns: ["agent_id"]; + isOneToOne: false; + referencedRelation: "agents"; + referencedColumns: ["id"]; }, { - foreignKeyName: "agent_status_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "agent_status_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; agent_template_favorites: { Row: { - created_at: string | null - template_id: string - user_id: string - } + created_at: string | null; + template_id: string; + user_id: string; + }; Insert: { - created_at?: string | null - template_id: string - user_id: string - } + created_at?: string | null; + template_id: string; + user_id: string; + }; Update: { - created_at?: string | null - template_id?: string - user_id?: string - } + created_at?: string | null; + template_id?: string; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "agent_template_favorites_template_id_fkey" - columns: ["template_id"] - isOneToOne: false - referencedRelation: "agent_templates" - referencedColumns: ["id"] + foreignKeyName: "agent_template_favorites_template_id_fkey"; + columns: ["template_id"]; + isOneToOne: false; + referencedRelation: "agent_templates"; + referencedColumns: ["id"]; }, { - foreignKeyName: "agent_template_favorites_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "agent_template_favorites_user_id_fkey"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; agent_template_shares: { Row: { - created_at: string | null - template_id: string - user_id: string - } + created_at: string | null; + template_id: string; + user_id: string; + }; Insert: { - created_at?: string | null - template_id: string - user_id: string - } + created_at?: string | null; + template_id: string; + user_id: string; + }; Update: { - created_at?: string | null - template_id?: string - user_id?: string - } + created_at?: string | null; + template_id?: string; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "agent_template_shares_template_id_fkey" - columns: ["template_id"] - isOneToOne: false - referencedRelation: "agent_templates" - referencedColumns: ["id"] + foreignKeyName: "agent_template_shares_template_id_fkey"; + columns: ["template_id"]; + isOneToOne: false; + referencedRelation: "agent_templates"; + referencedColumns: ["id"]; }, { - foreignKeyName: "agent_template_shares_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "agent_template_shares_user_id_fkey"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; agent_templates: { Row: { - created_at: string - creator: string | null - description: string - favorites_count: number - id: string - is_private: boolean - prompt: string - tags: string[] - title: string - updated_at: string | null - } - Insert: { - created_at?: string - creator?: string | null - description: string - favorites_count?: number - id?: string - is_private?: boolean - prompt: string - tags?: string[] - title: string - updated_at?: string | null - } - Update: { - created_at?: string - creator?: string | null - description?: string - favorites_count?: number - id?: string - is_private?: boolean - prompt?: string - tags?: string[] - title?: string - updated_at?: string | null - } + created_at: string; + creator: string | null; + description: string; + favorites_count: number; + id: string; + is_private: boolean; + prompt: string; + tags: string[]; + title: string; + updated_at: string | null; + }; + Insert: { + created_at?: string; + creator?: string | null; + description: string; + favorites_count?: number; + id?: string; + is_private?: boolean; + prompt: string; + tags?: string[]; + title: string; + updated_at?: string | null; + }; + Update: { + created_at?: string; + creator?: string | null; + description?: string; + favorites_count?: number; + id?: string; + is_private?: boolean; + prompt?: string; + tags?: string[]; + title?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "agent_templates_creator_fkey" - columns: ["creator"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "agent_templates_creator_fkey"; + columns: ["creator"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; agents: { Row: { - id: string - updated_at: string - } + id: string; + updated_at: string; + }; Insert: { - id?: string - updated_at?: string - } + id?: string; + updated_at?: string; + }; Update: { - id?: string - updated_at?: string - } - Relationships: [] - } + id?: string; + updated_at?: string; + }; + Relationships: []; + }; app_store_link_clicked: { Row: { - clientId: string | null - id: string | null - timestamp: number | null - } - Insert: { - clientId?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - clientId?: string | null - id?: string | null - timestamp?: number | null - } - Relationships: [] - } + clientId: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + clientId?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + clientId?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; apple_login_button_clicked: { Row: { - campaignId: string | null - clientId: string | null - fanId: string | null - game: string | null - id: string | null - timestamp: number | null - } - Insert: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: number | null - } + campaignId: string | null; + clientId: string | null; + fanId: string | null; + game: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; Relationships: [ { - foreignKeyName: "apple_login_button_clicked_campaignId_fkey" - columns: ["campaignId"] - isOneToOne: false - referencedRelation: "campaigns" - referencedColumns: ["id"] + foreignKeyName: "apple_login_button_clicked_campaignId_fkey"; + columns: ["campaignId"]; + isOneToOne: false; + referencedRelation: "campaigns"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; apple_music: { Row: { - fanId: string | null - game: string | null - id: string | null - syncid: string | null - syncId: string | null - timestamp: number | null - } - Insert: { - fanId?: string | null - game?: string | null - id?: string | null - syncid?: string | null - syncId?: string | null - timestamp?: number | null - } - Update: { - fanId?: string | null - game?: string | null - id?: string | null - syncid?: string | null - syncId?: string | null - timestamp?: number | null - } - Relationships: [] - } + fanId: string | null; + game: string | null; + id: string | null; + syncid: string | null; + syncId: string | null; + timestamp: number | null; + }; + Insert: { + fanId?: string | null; + game?: string | null; + id?: string | null; + syncid?: string | null; + syncId?: string | null; + timestamp?: number | null; + }; + Update: { + fanId?: string | null; + game?: string | null; + id?: string | null; + syncid?: string | null; + syncId?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; apple_play_button_clicked: { Row: { - appleId: string | null - campaignId: string | null - clientId: string | null - fanId: string | null - game: string | null - id: string - timestamp: number | null - } - Insert: { - appleId?: string | null - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string - timestamp?: number | null - } - Update: { - appleId?: string | null - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string - timestamp?: number | null - } + appleId: string | null; + campaignId: string | null; + clientId: string | null; + fanId: string | null; + game: string | null; + id: string; + timestamp: number | null; + }; + Insert: { + appleId?: string | null; + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string; + timestamp?: number | null; + }; + Update: { + appleId?: string | null; + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string; + timestamp?: number | null; + }; + Relationships: [ + { + foreignKeyName: "apple_play_button_clicked_campaignId_fkey"; + columns: ["campaignId"]; + isOneToOne: false; + referencedRelation: "campaigns"; + referencedColumns: ["id"]; + }, + ]; + }; + artist_composio_connections: { + Row: { + id: string; + artist_id: string; + toolkit_slug: string; + connected_account_id: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + artist_id: string; + toolkit_slug: string; + connected_account_id: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + artist_id?: string; + toolkit_slug?: string; + connected_account_id?: string; + created_at?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "apple_play_button_clicked_campaignId_fkey" - columns: ["campaignId"] - isOneToOne: false - referencedRelation: "campaigns" - referencedColumns: ["id"] + foreignKeyName: "artist_composio_connections_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "account_info"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; artist_fan_segment: { Row: { - artist_social_id: string | null - fan_social_id: string | null - id: string - segment_name: string | null - updated_at: string - } - Insert: { - artist_social_id?: string | null - fan_social_id?: string | null - id?: string - segment_name?: string | null - updated_at?: string - } - Update: { - artist_social_id?: string | null - fan_social_id?: string | null - id?: string - segment_name?: string | null - updated_at?: string - } + artist_social_id: string | null; + fan_social_id: string | null; + id: string; + segment_name: string | null; + updated_at: string; + }; + Insert: { + artist_social_id?: string | null; + fan_social_id?: string | null; + id?: string; + segment_name?: string | null; + updated_at?: string; + }; + Update: { + artist_social_id?: string | null; + fan_social_id?: string | null; + id?: string; + segment_name?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "artist_fan_segment_artist_social_id_fkey" - columns: ["artist_social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "artist_fan_segment_artist_social_id_fkey"; + columns: ["artist_social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, { - foreignKeyName: "artist_fan_segment_fan_social_id_fkey" - columns: ["fan_social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "artist_fan_segment_fan_social_id_fkey"; + columns: ["fan_social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; artist_organization_ids: { Row: { - artist_id: string - created_at: string | null - id: string - organization_id: string - updated_at: string | null - } - Insert: { - artist_id: string - created_at?: string | null - id?: string - organization_id: string - updated_at?: string | null - } - Update: { - artist_id?: string - created_at?: string | null - id?: string - organization_id?: string - updated_at?: string | null - } + artist_id: string; + created_at: string | null; + id: string; + organization_id: string; + updated_at: string | null; + }; + Insert: { + artist_id: string; + created_at?: string | null; + id?: string; + organization_id: string; + updated_at?: string | null; + }; + Update: { + artist_id?: string; + created_at?: string | null; + id?: string; + organization_id?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "artist_organization_ids_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "artist_organization_ids_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "artist_organization_ids_organization_id_fkey" - columns: ["organization_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "artist_organization_ids_organization_id_fkey"; + columns: ["organization_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; artist_segments: { Row: { - artist_account_id: string - id: string - segment_id: string - updated_at: string | null - } - Insert: { - artist_account_id: string - id?: string - segment_id: string - updated_at?: string | null - } - Update: { - artist_account_id?: string - id?: string - segment_id?: string - updated_at?: string | null - } + artist_account_id: string; + id: string; + segment_id: string; + updated_at: string | null; + }; + Insert: { + artist_account_id: string; + id?: string; + segment_id: string; + updated_at?: string | null; + }; + Update: { + artist_account_id?: string; + id?: string; + segment_id?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "artist_segments_artist_account_id_fkey" - columns: ["artist_account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "artist_segments_artist_account_id_fkey"; + columns: ["artist_account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "artist_segments_segment_id_fkey" - columns: ["segment_id"] - isOneToOne: false - referencedRelation: "segments" - referencedColumns: ["id"] + foreignKeyName: "artist_segments_segment_id_fkey"; + columns: ["segment_id"]; + isOneToOne: false; + referencedRelation: "segments"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; billing_customers: { Row: { - account_id: string - customer_id: string - email: string | null - id: number - provider: Database["public"]["Enums"]["billing_provider"] - } - Insert: { - account_id: string - customer_id: string - email?: string | null - id?: number - provider: Database["public"]["Enums"]["billing_provider"] - } - Update: { - account_id?: string - customer_id?: string - email?: string | null - id?: number - provider?: Database["public"]["Enums"]["billing_provider"] - } - Relationships: [] - } + account_id: string; + customer_id: string; + email: string | null; + id: number; + provider: Database["public"]["Enums"]["billing_provider"]; + }; + Insert: { + account_id: string; + customer_id: string; + email?: string | null; + id?: number; + provider: Database["public"]["Enums"]["billing_provider"]; + }; + Update: { + account_id?: string; + customer_id?: string; + email?: string | null; + id?: number; + provider?: Database["public"]["Enums"]["billing_provider"]; + }; + Relationships: []; + }; campaigns: { Row: { - artist_id: string | null - clientId: string | null - id: string - timestamp: number | null - } - Insert: { - artist_id?: string | null - clientId?: string | null - id?: string - timestamp?: number | null - } - Update: { - artist_id?: string | null - clientId?: string | null - id?: string - timestamp?: number | null - } + artist_id: string | null; + clientId: string | null; + id: string; + timestamp: number | null; + }; + Insert: { + artist_id?: string | null; + clientId?: string | null; + id?: string; + timestamp?: number | null; + }; + Update: { + artist_id?: string | null; + clientId?: string | null; + id?: string; + timestamp?: number | null; + }; Relationships: [ { - foreignKeyName: "campaigns_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "campaigns_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; catalog_songs: { Row: { - catalog: string - created_at: string - id: string - song: string - updated_at: string - } - Insert: { - catalog: string - created_at?: string - id?: string - song: string - updated_at?: string - } - Update: { - catalog?: string - created_at?: string - id?: string - song?: string - updated_at?: string - } + catalog: string; + created_at: string; + id: string; + song: string; + updated_at: string; + }; + Insert: { + catalog: string; + created_at?: string; + id?: string; + song: string; + updated_at?: string; + }; + Update: { + catalog?: string; + created_at?: string; + id?: string; + song?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "catalog_songs_catalog_fkey" - columns: ["catalog"] - isOneToOne: false - referencedRelation: "catalogs" - referencedColumns: ["id"] + foreignKeyName: "catalog_songs_catalog_fkey"; + columns: ["catalog"]; + isOneToOne: false; + referencedRelation: "catalogs"; + referencedColumns: ["id"]; }, { - foreignKeyName: "catalog_songs_song_fkey" - columns: ["song"] - isOneToOne: false - referencedRelation: "songs" - referencedColumns: ["isrc"] + foreignKeyName: "catalog_songs_song_fkey"; + columns: ["song"]; + isOneToOne: false; + referencedRelation: "songs"; + referencedColumns: ["isrc"]; }, - ] - } + ]; + }; catalogs: { Row: { - created_at: string - id: string - name: string - updated_at: string - } - Insert: { - created_at?: string - id?: string - name: string - updated_at?: string - } - Update: { - created_at?: string - id?: string - name?: string - updated_at?: string - } - Relationships: [] - } + created_at: string; + id: string; + name: string; + updated_at: string; + }; + Insert: { + created_at?: string; + id?: string; + name: string; + updated_at?: string; + }; + Update: { + created_at?: string; + id?: string; + name?: string; + updated_at?: string; + }; + Relationships: []; + }; config: { Row: { - billing_provider: Database["public"]["Enums"]["billing_provider"] - enable_account_billing: boolean - enable_team_account_billing: boolean - enable_team_accounts: boolean - } - Insert: { - billing_provider?: Database["public"]["Enums"]["billing_provider"] - enable_account_billing?: boolean - enable_team_account_billing?: boolean - enable_team_accounts?: boolean - } - Update: { - billing_provider?: Database["public"]["Enums"]["billing_provider"] - enable_account_billing?: boolean - enable_team_account_billing?: boolean - enable_team_accounts?: boolean - } - Relationships: [] - } + billing_provider: Database["public"]["Enums"]["billing_provider"]; + enable_account_billing: boolean; + enable_team_account_billing: boolean; + enable_team_accounts: boolean; + }; + Insert: { + billing_provider?: Database["public"]["Enums"]["billing_provider"]; + enable_account_billing?: boolean; + enable_team_account_billing?: boolean; + enable_team_accounts?: boolean; + }; + Update: { + billing_provider?: Database["public"]["Enums"]["billing_provider"]; + enable_account_billing?: boolean; + enable_team_account_billing?: boolean; + enable_team_accounts?: boolean; + }; + Relationships: []; + }; cookie_players: { Row: { - game: string | null - id: string | null - timestamp: number | null - uniquePlayerID: string | null - } - Insert: { - game?: string | null - id?: string | null - timestamp?: number | null - uniquePlayerID?: string | null - } - Update: { - game?: string | null - id?: string | null - timestamp?: number | null - uniquePlayerID?: string | null - } - Relationships: [] - } + game: string | null; + id: string | null; + timestamp: number | null; + uniquePlayerID: string | null; + }; + Insert: { + game?: string | null; + id?: string | null; + timestamp?: number | null; + uniquePlayerID?: string | null; + }; + Update: { + game?: string | null; + id?: string | null; + timestamp?: number | null; + uniquePlayerID?: string | null; + }; + Relationships: []; + }; credits_usage: { Row: { - account_id: string - id: number - remaining_credits: number - timestamp: string | null - } - Insert: { - account_id: string - id?: number - remaining_credits?: number - timestamp?: string | null - } - Update: { - account_id?: string - id?: number - remaining_credits?: number - timestamp?: string | null - } + account_id: string; + id: number; + remaining_credits: number; + timestamp: string | null; + }; + Insert: { + account_id: string; + id?: number; + remaining_credits?: number; + timestamp?: string | null; + }; + Update: { + account_id?: string; + id?: number; + remaining_credits?: number; + timestamp?: string | null; + }; Relationships: [ { - foreignKeyName: "credits_usage_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "credits_usage_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; cta_redirect: { Row: { - clientId: string - id: number - timestamp: string | null - url: string | null - } - Insert: { - clientId: string - id?: number - timestamp?: string | null - url?: string | null - } - Update: { - clientId?: string - id?: number - timestamp?: string | null - url?: string | null - } - Relationships: [] - } + clientId: string; + id: number; + timestamp: string | null; + url: string | null; + }; + Insert: { + clientId: string; + id?: number; + timestamp?: string | null; + url?: string | null; + }; + Update: { + clientId?: string; + id?: number; + timestamp?: string | null; + url?: string | null; + }; + Relationships: []; + }; error_logs: { Row: { - account_id: string | null - created_at: string - error_message: string | null - error_timestamp: string | null - error_type: string | null - id: string - last_message: string | null - raw_message: string - room_id: string | null - stack_trace: string | null - telegram_message_id: number | null - tool_name: string | null - } - Insert: { - account_id?: string | null - created_at?: string - error_message?: string | null - error_timestamp?: string | null - error_type?: string | null - id?: string - last_message?: string | null - raw_message: string - room_id?: string | null - stack_trace?: string | null - telegram_message_id?: number | null - tool_name?: string | null - } - Update: { - account_id?: string | null - created_at?: string - error_message?: string | null - error_timestamp?: string | null - error_type?: string | null - id?: string - last_message?: string | null - raw_message?: string - room_id?: string | null - stack_trace?: string | null - telegram_message_id?: number | null - tool_name?: string | null - } + account_id: string | null; + created_at: string; + error_message: string | null; + error_timestamp: string | null; + error_type: string | null; + id: string; + last_message: string | null; + raw_message: string; + room_id: string | null; + stack_trace: string | null; + telegram_message_id: number | null; + tool_name: string | null; + }; + Insert: { + account_id?: string | null; + created_at?: string; + error_message?: string | null; + error_timestamp?: string | null; + error_type?: string | null; + id?: string; + last_message?: string | null; + raw_message: string; + room_id?: string | null; + stack_trace?: string | null; + telegram_message_id?: number | null; + tool_name?: string | null; + }; + Update: { + account_id?: string | null; + created_at?: string; + error_message?: string | null; + error_timestamp?: string | null; + error_type?: string | null; + id?: string; + last_message?: string | null; + raw_message?: string; + room_id?: string | null; + stack_trace?: string | null; + telegram_message_id?: number | null; + tool_name?: string | null; + }; Relationships: [ { - foreignKeyName: "error_logs_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "error_logs_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "error_logs_room_id_fkey" - columns: ["room_id"] - isOneToOne: false - referencedRelation: "rooms" - referencedColumns: ["id"] + foreignKeyName: "error_logs_room_id_fkey"; + columns: ["room_id"]; + isOneToOne: false; + referencedRelation: "rooms"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; fan_segments: { Row: { - fan_social_id: string - id: string - segment_id: string - updated_at: string | null - } - Insert: { - fan_social_id: string - id?: string - segment_id: string - updated_at?: string | null - } - Update: { - fan_social_id?: string - id?: string - segment_id?: string - updated_at?: string | null - } + fan_social_id: string; + id: string; + segment_id: string; + updated_at: string | null; + }; + Insert: { + fan_social_id: string; + id?: string; + segment_id: string; + updated_at?: string | null; + }; + Update: { + fan_social_id?: string; + id?: string; + segment_id?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "fan_segments_fan_social_id_fkey" - columns: ["fan_social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "fan_segments_fan_social_id_fkey"; + columns: ["fan_social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fan_segments_segment_id_fkey" - columns: ["segment_id"] - isOneToOne: false - referencedRelation: "segments" - referencedColumns: ["id"] + foreignKeyName: "fan_segments_segment_id_fkey"; + columns: ["segment_id"]; + isOneToOne: false; + referencedRelation: "segments"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; fans: { Row: { - account_status: string | null - apple_token: string | null - campaign_id: string | null - campaign_interaction_count: number | null - campaignId: string | null - city: string | null - click_through_rate: number | null - clientId: string | null - consent_given: boolean | null - country: string | null - custom_tags: Json | null - discord_username: string | null - display_name: string | null - email: string | null - email_open_rate: number | null - engagement_level: string | null - episodes: Json | null - explicit_content_filter_enabled: boolean | null - explicit_content_filter_locked: boolean | null - "explicit_content.filter_enabled": boolean | null - "explicit_content.filter_locked": boolean | null - external_urls_spotify: string | null - "external_urls.spotify": string | null - facebook_profile_url: string | null - first_stream_date: string | null - followedArtists: Json | null - followers_total: number | null - "followers.href": string | null - "followers.total": number | null - gamification_points: number | null - genres: Json | null - heavyRotations: Json | null - href: string | null - id: string - images: Json | null - instagram_handle: string | null - last_campaign_interaction: string | null - last_login: string | null - last_purchase_date: string | null - last_stream_date: string | null - linkedin_profile_url: string | null - os_type: string | null - playlist: Json | null - preferences: Json | null - preferred_artists: Json | null - preferred_device: string | null - product: string | null - recentlyPlayed: Json | null - recommendations: Json | null - recommended_events: Json | null - reddit_username: string | null - saved_podcasts: Json | null - savedAlbums: Json | null - savedAudioBooks: Json | null - savedShows: Json | null - savedTracks: Json | null - social_shares: number | null - spotify_token: string | null - subscription_tier: string | null - testField: string | null - tiktok_handle: string | null - time_zone: string | null - timestamp: string | null - top_artists_long_term: Json | null - top_artists_medium_term: Json | null - top_tracks_long_term: Json | null - top_tracks_medium_term: Json | null - top_tracks_short_term: Json | null - topArtists: Json | null - topTracks: Json | null - total_spent: number | null - total_streams: number | null - twitter_handle: string | null - type: string | null - uri: string | null - youtube_channel_url: string | null - } - Insert: { - account_status?: string | null - apple_token?: string | null - campaign_id?: string | null - campaign_interaction_count?: number | null - campaignId?: string | null - city?: string | null - click_through_rate?: number | null - clientId?: string | null - consent_given?: boolean | null - country?: string | null - custom_tags?: Json | null - discord_username?: string | null - display_name?: string | null - email?: string | null - email_open_rate?: number | null - engagement_level?: string | null - episodes?: Json | null - explicit_content_filter_enabled?: boolean | null - explicit_content_filter_locked?: boolean | null - "explicit_content.filter_enabled"?: boolean | null - "explicit_content.filter_locked"?: boolean | null - external_urls_spotify?: string | null - "external_urls.spotify"?: string | null - facebook_profile_url?: string | null - first_stream_date?: string | null - followedArtists?: Json | null - followers_total?: number | null - "followers.href"?: string | null - "followers.total"?: number | null - gamification_points?: number | null - genres?: Json | null - heavyRotations?: Json | null - href?: string | null - id?: string - images?: Json | null - instagram_handle?: string | null - last_campaign_interaction?: string | null - last_login?: string | null - last_purchase_date?: string | null - last_stream_date?: string | null - linkedin_profile_url?: string | null - os_type?: string | null - playlist?: Json | null - preferences?: Json | null - preferred_artists?: Json | null - preferred_device?: string | null - product?: string | null - recentlyPlayed?: Json | null - recommendations?: Json | null - recommended_events?: Json | null - reddit_username?: string | null - saved_podcasts?: Json | null - savedAlbums?: Json | null - savedAudioBooks?: Json | null - savedShows?: Json | null - savedTracks?: Json | null - social_shares?: number | null - spotify_token?: string | null - subscription_tier?: string | null - testField?: string | null - tiktok_handle?: string | null - time_zone?: string | null - timestamp?: string | null - top_artists_long_term?: Json | null - top_artists_medium_term?: Json | null - top_tracks_long_term?: Json | null - top_tracks_medium_term?: Json | null - top_tracks_short_term?: Json | null - topArtists?: Json | null - topTracks?: Json | null - total_spent?: number | null - total_streams?: number | null - twitter_handle?: string | null - type?: string | null - uri?: string | null - youtube_channel_url?: string | null - } - Update: { - account_status?: string | null - apple_token?: string | null - campaign_id?: string | null - campaign_interaction_count?: number | null - campaignId?: string | null - city?: string | null - click_through_rate?: number | null - clientId?: string | null - consent_given?: boolean | null - country?: string | null - custom_tags?: Json | null - discord_username?: string | null - display_name?: string | null - email?: string | null - email_open_rate?: number | null - engagement_level?: string | null - episodes?: Json | null - explicit_content_filter_enabled?: boolean | null - explicit_content_filter_locked?: boolean | null - "explicit_content.filter_enabled"?: boolean | null - "explicit_content.filter_locked"?: boolean | null - external_urls_spotify?: string | null - "external_urls.spotify"?: string | null - facebook_profile_url?: string | null - first_stream_date?: string | null - followedArtists?: Json | null - followers_total?: number | null - "followers.href"?: string | null - "followers.total"?: number | null - gamification_points?: number | null - genres?: Json | null - heavyRotations?: Json | null - href?: string | null - id?: string - images?: Json | null - instagram_handle?: string | null - last_campaign_interaction?: string | null - last_login?: string | null - last_purchase_date?: string | null - last_stream_date?: string | null - linkedin_profile_url?: string | null - os_type?: string | null - playlist?: Json | null - preferences?: Json | null - preferred_artists?: Json | null - preferred_device?: string | null - product?: string | null - recentlyPlayed?: Json | null - recommendations?: Json | null - recommended_events?: Json | null - reddit_username?: string | null - saved_podcasts?: Json | null - savedAlbums?: Json | null - savedAudioBooks?: Json | null - savedShows?: Json | null - savedTracks?: Json | null - social_shares?: number | null - spotify_token?: string | null - subscription_tier?: string | null - testField?: string | null - tiktok_handle?: string | null - time_zone?: string | null - timestamp?: string | null - top_artists_long_term?: Json | null - top_artists_medium_term?: Json | null - top_tracks_long_term?: Json | null - top_tracks_medium_term?: Json | null - top_tracks_short_term?: Json | null - topArtists?: Json | null - topTracks?: Json | null - total_spent?: number | null - total_streams?: number | null - twitter_handle?: string | null - type?: string | null - uri?: string | null - youtube_channel_url?: string | null - } + account_status: string | null; + apple_token: string | null; + campaign_id: string | null; + campaign_interaction_count: number | null; + campaignId: string | null; + city: string | null; + click_through_rate: number | null; + clientId: string | null; + consent_given: boolean | null; + country: string | null; + custom_tags: Json | null; + discord_username: string | null; + display_name: string | null; + email: string | null; + email_open_rate: number | null; + engagement_level: string | null; + episodes: Json | null; + explicit_content_filter_enabled: boolean | null; + explicit_content_filter_locked: boolean | null; + "explicit_content.filter_enabled": boolean | null; + "explicit_content.filter_locked": boolean | null; + external_urls_spotify: string | null; + "external_urls.spotify": string | null; + facebook_profile_url: string | null; + first_stream_date: string | null; + followedArtists: Json | null; + followers_total: number | null; + "followers.href": string | null; + "followers.total": number | null; + gamification_points: number | null; + genres: Json | null; + heavyRotations: Json | null; + href: string | null; + id: string; + images: Json | null; + instagram_handle: string | null; + last_campaign_interaction: string | null; + last_login: string | null; + last_purchase_date: string | null; + last_stream_date: string | null; + linkedin_profile_url: string | null; + os_type: string | null; + playlist: Json | null; + preferences: Json | null; + preferred_artists: Json | null; + preferred_device: string | null; + product: string | null; + recentlyPlayed: Json | null; + recommendations: Json | null; + recommended_events: Json | null; + reddit_username: string | null; + saved_podcasts: Json | null; + savedAlbums: Json | null; + savedAudioBooks: Json | null; + savedShows: Json | null; + savedTracks: Json | null; + social_shares: number | null; + spotify_token: string | null; + subscription_tier: string | null; + testField: string | null; + tiktok_handle: string | null; + time_zone: string | null; + timestamp: string | null; + top_artists_long_term: Json | null; + top_artists_medium_term: Json | null; + top_tracks_long_term: Json | null; + top_tracks_medium_term: Json | null; + top_tracks_short_term: Json | null; + topArtists: Json | null; + topTracks: Json | null; + total_spent: number | null; + total_streams: number | null; + twitter_handle: string | null; + type: string | null; + uri: string | null; + youtube_channel_url: string | null; + }; + Insert: { + account_status?: string | null; + apple_token?: string | null; + campaign_id?: string | null; + campaign_interaction_count?: number | null; + campaignId?: string | null; + city?: string | null; + click_through_rate?: number | null; + clientId?: string | null; + consent_given?: boolean | null; + country?: string | null; + custom_tags?: Json | null; + discord_username?: string | null; + display_name?: string | null; + email?: string | null; + email_open_rate?: number | null; + engagement_level?: string | null; + episodes?: Json | null; + explicit_content_filter_enabled?: boolean | null; + explicit_content_filter_locked?: boolean | null; + "explicit_content.filter_enabled"?: boolean | null; + "explicit_content.filter_locked"?: boolean | null; + external_urls_spotify?: string | null; + "external_urls.spotify"?: string | null; + facebook_profile_url?: string | null; + first_stream_date?: string | null; + followedArtists?: Json | null; + followers_total?: number | null; + "followers.href"?: string | null; + "followers.total"?: number | null; + gamification_points?: number | null; + genres?: Json | null; + heavyRotations?: Json | null; + href?: string | null; + id?: string; + images?: Json | null; + instagram_handle?: string | null; + last_campaign_interaction?: string | null; + last_login?: string | null; + last_purchase_date?: string | null; + last_stream_date?: string | null; + linkedin_profile_url?: string | null; + os_type?: string | null; + playlist?: Json | null; + preferences?: Json | null; + preferred_artists?: Json | null; + preferred_device?: string | null; + product?: string | null; + recentlyPlayed?: Json | null; + recommendations?: Json | null; + recommended_events?: Json | null; + reddit_username?: string | null; + saved_podcasts?: Json | null; + savedAlbums?: Json | null; + savedAudioBooks?: Json | null; + savedShows?: Json | null; + savedTracks?: Json | null; + social_shares?: number | null; + spotify_token?: string | null; + subscription_tier?: string | null; + testField?: string | null; + tiktok_handle?: string | null; + time_zone?: string | null; + timestamp?: string | null; + top_artists_long_term?: Json | null; + top_artists_medium_term?: Json | null; + top_tracks_long_term?: Json | null; + top_tracks_medium_term?: Json | null; + top_tracks_short_term?: Json | null; + topArtists?: Json | null; + topTracks?: Json | null; + total_spent?: number | null; + total_streams?: number | null; + twitter_handle?: string | null; + type?: string | null; + uri?: string | null; + youtube_channel_url?: string | null; + }; + Update: { + account_status?: string | null; + apple_token?: string | null; + campaign_id?: string | null; + campaign_interaction_count?: number | null; + campaignId?: string | null; + city?: string | null; + click_through_rate?: number | null; + clientId?: string | null; + consent_given?: boolean | null; + country?: string | null; + custom_tags?: Json | null; + discord_username?: string | null; + display_name?: string | null; + email?: string | null; + email_open_rate?: number | null; + engagement_level?: string | null; + episodes?: Json | null; + explicit_content_filter_enabled?: boolean | null; + explicit_content_filter_locked?: boolean | null; + "explicit_content.filter_enabled"?: boolean | null; + "explicit_content.filter_locked"?: boolean | null; + external_urls_spotify?: string | null; + "external_urls.spotify"?: string | null; + facebook_profile_url?: string | null; + first_stream_date?: string | null; + followedArtists?: Json | null; + followers_total?: number | null; + "followers.href"?: string | null; + "followers.total"?: number | null; + gamification_points?: number | null; + genres?: Json | null; + heavyRotations?: Json | null; + href?: string | null; + id?: string; + images?: Json | null; + instagram_handle?: string | null; + last_campaign_interaction?: string | null; + last_login?: string | null; + last_purchase_date?: string | null; + last_stream_date?: string | null; + linkedin_profile_url?: string | null; + os_type?: string | null; + playlist?: Json | null; + preferences?: Json | null; + preferred_artists?: Json | null; + preferred_device?: string | null; + product?: string | null; + recentlyPlayed?: Json | null; + recommendations?: Json | null; + recommended_events?: Json | null; + reddit_username?: string | null; + saved_podcasts?: Json | null; + savedAlbums?: Json | null; + savedAudioBooks?: Json | null; + savedShows?: Json | null; + savedTracks?: Json | null; + social_shares?: number | null; + spotify_token?: string | null; + subscription_tier?: string | null; + testField?: string | null; + tiktok_handle?: string | null; + time_zone?: string | null; + timestamp?: string | null; + top_artists_long_term?: Json | null; + top_artists_medium_term?: Json | null; + top_tracks_long_term?: Json | null; + top_tracks_medium_term?: Json | null; + top_tracks_short_term?: Json | null; + topArtists?: Json | null; + topTracks?: Json | null; + total_spent?: number | null; + total_streams?: number | null; + twitter_handle?: string | null; + type?: string | null; + uri?: string | null; + youtube_channel_url?: string | null; + }; Relationships: [ { - foreignKeyName: "fans_campaignId_fkey" - columns: ["campaignId"] - isOneToOne: false - referencedRelation: "campaigns" - referencedColumns: ["id"] + foreignKeyName: "fans_campaignId_fkey"; + columns: ["campaignId"]; + isOneToOne: false; + referencedRelation: "campaigns"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; files: { Row: { - artist_account_id: string - created_at: string - description: string | null - file_name: string - id: string - is_directory: boolean - mime_type: string | null - owner_account_id: string - size_bytes: number | null - storage_key: string - tags: string[] | null - updated_at: string - } - Insert: { - artist_account_id: string - created_at?: string - description?: string | null - file_name: string - id?: string - is_directory?: boolean - mime_type?: string | null - owner_account_id: string - size_bytes?: number | null - storage_key: string - tags?: string[] | null - updated_at?: string - } - Update: { - artist_account_id?: string - created_at?: string - description?: string | null - file_name?: string - id?: string - is_directory?: boolean - mime_type?: string | null - owner_account_id?: string - size_bytes?: number | null - storage_key?: string - tags?: string[] | null - updated_at?: string - } + artist_account_id: string; + created_at: string; + description: string | null; + file_name: string; + id: string; + is_directory: boolean; + mime_type: string | null; + owner_account_id: string; + size_bytes: number | null; + storage_key: string; + tags: string[] | null; + updated_at: string; + }; + Insert: { + artist_account_id: string; + created_at?: string; + description?: string | null; + file_name: string; + id?: string; + is_directory?: boolean; + mime_type?: string | null; + owner_account_id: string; + size_bytes?: number | null; + storage_key: string; + tags?: string[] | null; + updated_at?: string; + }; + Update: { + artist_account_id?: string; + created_at?: string; + description?: string | null; + file_name?: string; + id?: string; + is_directory?: boolean; + mime_type?: string | null; + owner_account_id?: string; + size_bytes?: number | null; + storage_key?: string; + tags?: string[] | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "files_artist_account_id_fkey" - columns: ["artist_account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "files_artist_account_id_fkey"; + columns: ["artist_account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "files_owner_account_id_fkey" - columns: ["owner_account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "files_owner_account_id_fkey"; + columns: ["owner_account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; follows: { Row: { - game: string | null - id: string | null - timestamp: number | null - } - Insert: { - game?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - game?: string | null - id?: string | null - timestamp?: number | null - } - Relationships: [] - } + game: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; founder_dashboard_chart_annotations: { Row: { - chart_type: string | null - created_at: string | null - event_date: string - event_description: string | null - id: string - } - Insert: { - chart_type?: string | null - created_at?: string | null - event_date: string - event_description?: string | null - id?: string - } - Update: { - chart_type?: string | null - created_at?: string | null - event_date?: string - event_description?: string | null - id?: string - } - Relationships: [] - } + chart_type: string | null; + created_at: string | null; + event_date: string; + event_description: string | null; + id: string; + }; + Insert: { + chart_type?: string | null; + created_at?: string | null; + event_date: string; + event_description?: string | null; + id?: string; + }; + Update: { + chart_type?: string | null; + created_at?: string | null; + event_date?: string; + event_description?: string | null; + id?: string; + }; + Relationships: []; + }; funnel_analytics: { Row: { - artist_id: string | null - handle: string | null - id: string - pilot_id: string | null - status: number | null - type: Database["public"]["Enums"]["social_type"] | null - updated_at: string - } - Insert: { - artist_id?: string | null - handle?: string | null - id?: string - pilot_id?: string | null - status?: number | null - type?: Database["public"]["Enums"]["social_type"] | null - updated_at?: string - } - Update: { - artist_id?: string | null - handle?: string | null - id?: string - pilot_id?: string | null - status?: number | null - type?: Database["public"]["Enums"]["social_type"] | null - updated_at?: string - } + artist_id: string | null; + handle: string | null; + id: string; + pilot_id: string | null; + status: number | null; + type: Database["public"]["Enums"]["social_type"] | null; + updated_at: string; + }; + Insert: { + artist_id?: string | null; + handle?: string | null; + id?: string; + pilot_id?: string | null; + status?: number | null; + type?: Database["public"]["Enums"]["social_type"] | null; + updated_at?: string; + }; + Update: { + artist_id?: string | null; + handle?: string | null; + id?: string; + pilot_id?: string | null; + status?: number | null; + type?: Database["public"]["Enums"]["social_type"] | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "funnel_analytics_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "funnel_analytics_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; funnel_analytics_accounts: { Row: { - account_id: string | null - analysis_id: string | null - created_at: string - id: string - updated_at: string | null - } - Insert: { - account_id?: string | null - analysis_id?: string | null - created_at?: string - id?: string - updated_at?: string | null - } - Update: { - account_id?: string | null - analysis_id?: string | null - created_at?: string - id?: string - updated_at?: string | null - } + account_id: string | null; + analysis_id: string | null; + created_at: string; + id: string; + updated_at: string | null; + }; + Insert: { + account_id?: string | null; + analysis_id?: string | null; + created_at?: string; + id?: string; + updated_at?: string | null; + }; + Update: { + account_id?: string | null; + analysis_id?: string | null; + created_at?: string; + id?: string; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "account_funnel_analytics_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "account_funnel_analytics_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "account_funnel_analytics_analysis_id_fkey" - columns: ["analysis_id"] - isOneToOne: false - referencedRelation: "funnel_analytics" - referencedColumns: ["id"] + foreignKeyName: "account_funnel_analytics_analysis_id_fkey"; + columns: ["analysis_id"]; + isOneToOne: false; + referencedRelation: "funnel_analytics"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; funnel_analytics_segments: { Row: { - analysis_id: string | null - created_at: string - icon: string | null - id: string - name: string | null - size: number | null - } - Insert: { - analysis_id?: string | null - created_at?: string - icon?: string | null - id?: string - name?: string | null - size?: number | null - } - Update: { - analysis_id?: string | null - created_at?: string - icon?: string | null - id?: string - name?: string | null - size?: number | null - } + analysis_id: string | null; + created_at: string; + icon: string | null; + id: string; + name: string | null; + size: number | null; + }; + Insert: { + analysis_id?: string | null; + created_at?: string; + icon?: string | null; + id?: string; + name?: string | null; + size?: number | null; + }; + Update: { + analysis_id?: string | null; + created_at?: string; + icon?: string | null; + id?: string; + name?: string | null; + size?: number | null; + }; Relationships: [ { - foreignKeyName: "funnel_analytics_segments_analysis_id_fkey" - columns: ["analysis_id"] - isOneToOne: false - referencedRelation: "funnel_analytics" - referencedColumns: ["id"] + foreignKeyName: "funnel_analytics_segments_analysis_id_fkey"; + columns: ["analysis_id"]; + isOneToOne: false; + referencedRelation: "funnel_analytics"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; funnel_reports: { Row: { - id: string - next_steps: string | null - report: string | null - stack_unique_id: string | null - timestamp: string - type: Database["public"]["Enums"]["social_type"] | null - } - Insert: { - id?: string - next_steps?: string | null - report?: string | null - stack_unique_id?: string | null - timestamp?: string - type?: Database["public"]["Enums"]["social_type"] | null - } - Update: { - id?: string - next_steps?: string | null - report?: string | null - stack_unique_id?: string | null - timestamp?: string - type?: Database["public"]["Enums"]["social_type"] | null - } - Relationships: [] - } + id: string; + next_steps: string | null; + report: string | null; + stack_unique_id: string | null; + timestamp: string; + type: Database["public"]["Enums"]["social_type"] | null; + }; + Insert: { + id?: string; + next_steps?: string | null; + report?: string | null; + stack_unique_id?: string | null; + timestamp?: string; + type?: Database["public"]["Enums"]["social_type"] | null; + }; + Update: { + id?: string; + next_steps?: string | null; + report?: string | null; + stack_unique_id?: string | null; + timestamp?: string; + type?: Database["public"]["Enums"]["social_type"] | null; + }; + Relationships: []; + }; game_start: { Row: { - clientId: string | null - fanId: Json | null - game: string | null - id: string | null - timestamp: number | null - } - Insert: { - clientId?: string | null - fanId?: Json | null - game?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - clientId?: string | null - fanId?: Json | null - game?: string | null - id?: string | null - timestamp?: number | null - } - Relationships: [] - } + clientId: string | null; + fanId: Json | null; + game: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + clientId?: string | null; + fanId?: Json | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + clientId?: string | null; + fanId?: Json | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; invitations: { Row: { - account_id: string - created_at: string - email: string - expires_at: string - id: number - invite_token: string - invited_by: string - role: string - updated_at: string - } - Insert: { - account_id: string - created_at?: string - email: string - expires_at?: string - id?: number - invite_token: string - invited_by: string - role: string - updated_at?: string - } - Update: { - account_id?: string - created_at?: string - email?: string - expires_at?: string - id?: number - invite_token?: string - invited_by?: string - role?: string - updated_at?: string - } + account_id: string; + created_at: string; + email: string; + expires_at: string; + id: number; + invite_token: string; + invited_by: string; + role: string; + updated_at: string; + }; + Insert: { + account_id: string; + created_at?: string; + email: string; + expires_at?: string; + id?: number; + invite_token: string; + invited_by: string; + role: string; + updated_at?: string; + }; + Update: { + account_id?: string; + created_at?: string; + email?: string; + expires_at?: string; + id?: number; + invite_token?: string; + invited_by?: string; + role?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "invitations_role_fkey" - columns: ["role"] - isOneToOne: false - referencedRelation: "roles" - referencedColumns: ["name"] + foreignKeyName: "invitations_role_fkey"; + columns: ["role"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["name"]; }, - ] - } + ]; + }; ios_redirect: { Row: { - clientId: string | null - fanId: string | null - id: string | null - timestamp: number | null - } - Insert: { - clientId?: string | null - fanId?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - clientId?: string | null - fanId?: string | null - id?: string | null - timestamp?: number | null - } - Relationships: [] - } + clientId: string | null; + fanId: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + clientId?: string | null; + fanId?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + clientId?: string | null; + fanId?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; leaderboard: { Row: { - id: string | null - Name: string | null - Number: string | null - Score: string | null - Spotify: string | null - "Time._nanoseconds": string | null - "Time._seconds": string | null - } - Insert: { - id?: string | null - Name?: string | null - Number?: string | null - Score?: string | null - Spotify?: string | null - "Time._nanoseconds"?: string | null - "Time._seconds"?: string | null - } - Update: { - id?: string | null - Name?: string | null - Number?: string | null - Score?: string | null - Spotify?: string | null - "Time._nanoseconds"?: string | null - "Time._seconds"?: string | null - } - Relationships: [] - } + id: string | null; + Name: string | null; + Number: string | null; + Score: string | null; + Spotify: string | null; + "Time._nanoseconds": string | null; + "Time._seconds": string | null; + }; + Insert: { + id?: string | null; + Name?: string | null; + Number?: string | null; + Score?: string | null; + Spotify?: string | null; + "Time._nanoseconds"?: string | null; + "Time._seconds"?: string | null; + }; + Update: { + id?: string | null; + Name?: string | null; + Number?: string | null; + Score?: string | null; + Spotify?: string | null; + "Time._nanoseconds"?: string | null; + "Time._seconds"?: string | null; + }; + Relationships: []; + }; leaderboard_boogie: { Row: { - clientId: string | null - displayName: string | null - fanId: string | null - gameType: string | null - id: string | null - score: number | null - timestamp: string | null - } - Insert: { - clientId?: string | null - displayName?: string | null - fanId?: string | null - gameType?: string | null - id?: string | null - score?: number | null - timestamp?: string | null - } - Update: { - clientId?: string | null - displayName?: string | null - fanId?: string | null - gameType?: string | null - id?: string | null - score?: number | null - timestamp?: string | null - } - Relationships: [] - } + clientId: string | null; + displayName: string | null; + fanId: string | null; + gameType: string | null; + id: string | null; + score: number | null; + timestamp: string | null; + }; + Insert: { + clientId?: string | null; + displayName?: string | null; + fanId?: string | null; + gameType?: string | null; + id?: string | null; + score?: number | null; + timestamp?: string | null; + }; + Update: { + clientId?: string | null; + displayName?: string | null; + fanId?: string | null; + gameType?: string | null; + id?: string | null; + score?: number | null; + timestamp?: string | null; + }; + Relationships: []; + }; leaderboard_luh_tyler_3d: { Row: { - FanId: string | null - id: string | null - Score: string | null - ScorePerTime: string | null - Time: string | null - timestamp: string | null - UserName: string | null - } - Insert: { - FanId?: string | null - id?: string | null - Score?: string | null - ScorePerTime?: string | null - Time?: string | null - timestamp?: string | null - UserName?: string | null - } - Update: { - FanId?: string | null - id?: string | null - Score?: string | null - ScorePerTime?: string | null - Time?: string | null - timestamp?: string | null - UserName?: string | null - } - Relationships: [] - } + FanId: string | null; + id: string | null; + Score: string | null; + ScorePerTime: string | null; + Time: string | null; + timestamp: string | null; + UserName: string | null; + }; + Insert: { + FanId?: string | null; + id?: string | null; + Score?: string | null; + ScorePerTime?: string | null; + Time?: string | null; + timestamp?: string | null; + UserName?: string | null; + }; + Update: { + FanId?: string | null; + id?: string | null; + Score?: string | null; + ScorePerTime?: string | null; + Time?: string | null; + timestamp?: string | null; + UserName?: string | null; + }; + Relationships: []; + }; leaderboard_luv: { Row: { - f: string | null - id: string | null - } + f: string | null; + id: string | null; + }; Insert: { - f?: string | null - id?: string | null - } + f?: string | null; + id?: string | null; + }; Update: { - f?: string | null - id?: string | null - } - Relationships: [] - } + f?: string | null; + id?: string | null; + }; + Relationships: []; + }; memories: { Row: { - content: Json - id: string - room_id: string | null - updated_at: string - } - Insert: { - content: Json - id?: string - room_id?: string | null - updated_at?: string - } - Update: { - content?: Json - id?: string - room_id?: string | null - updated_at?: string - } + content: Json; + id: string; + room_id: string | null; + updated_at: string; + }; + Insert: { + content: Json; + id?: string; + room_id?: string | null; + updated_at?: string; + }; + Update: { + content?: Json; + id?: string; + room_id?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "memories_room_id_fkey" - columns: ["room_id"] - isOneToOne: false - referencedRelation: "rooms" - referencedColumns: ["id"] + foreignKeyName: "memories_room_id_fkey"; + columns: ["room_id"]; + isOneToOne: false; + referencedRelation: "rooms"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; memory_emails: { Row: { - created_at: string - email_id: string - id: string - memory: string - message_id: string - } - Insert: { - created_at?: string - email_id: string - id?: string - memory: string - message_id: string - } - Update: { - created_at?: string - email_id?: string - id?: string - memory?: string - message_id?: string - } + created_at: string; + email_id: string; + id: string; + memory: string; + message_id: string; + }; + Insert: { + created_at?: string; + email_id: string; + id?: string; + memory: string; + message_id: string; + }; + Update: { + created_at?: string; + email_id?: string; + id?: string; + memory?: string; + message_id?: string; + }; Relationships: [ { - foreignKeyName: "memory_emails_memory_fkey" - columns: ["memory"] - isOneToOne: false - referencedRelation: "memories" - referencedColumns: ["id"] + foreignKeyName: "memory_emails_memory_fkey"; + columns: ["memory"]; + isOneToOne: false; + referencedRelation: "memories"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; notifications: { Row: { - account_id: string - body: string - channel: Database["public"]["Enums"]["notification_channel"] - created_at: string - dismissed: boolean - expires_at: string | null - id: number - link: string | null - type: Database["public"]["Enums"]["notification_type"] - } - Insert: { - account_id: string - body: string - channel?: Database["public"]["Enums"]["notification_channel"] - created_at?: string - dismissed?: boolean - expires_at?: string | null - id?: never - link?: string | null - type?: Database["public"]["Enums"]["notification_type"] - } - Update: { - account_id?: string - body?: string - channel?: Database["public"]["Enums"]["notification_channel"] - created_at?: string - dismissed?: boolean - expires_at?: string | null - id?: never - link?: string | null - type?: Database["public"]["Enums"]["notification_type"] - } - Relationships: [] - } + account_id: string; + body: string; + channel: Database["public"]["Enums"]["notification_channel"]; + created_at: string; + dismissed: boolean; + expires_at: string | null; + id: number; + link: string | null; + type: Database["public"]["Enums"]["notification_type"]; + }; + Insert: { + account_id: string; + body: string; + channel?: Database["public"]["Enums"]["notification_channel"]; + created_at?: string; + dismissed?: boolean; + expires_at?: string | null; + id?: never; + link?: string | null; + type?: Database["public"]["Enums"]["notification_type"]; + }; + Update: { + account_id?: string; + body?: string; + channel?: Database["public"]["Enums"]["notification_channel"]; + created_at?: string; + dismissed?: boolean; + expires_at?: string | null; + id?: never; + link?: string | null; + type?: Database["public"]["Enums"]["notification_type"]; + }; + Relationships: []; + }; order_items: { Row: { - created_at: string - id: string - order_id: string - price_amount: number | null - product_id: string - quantity: number - updated_at: string - variant_id: string - } - Insert: { - created_at?: string - id: string - order_id: string - price_amount?: number | null - product_id: string - quantity?: number - updated_at?: string - variant_id: string - } - Update: { - created_at?: string - id?: string - order_id?: string - price_amount?: number | null - product_id?: string - quantity?: number - updated_at?: string - variant_id?: string - } + created_at: string; + id: string; + order_id: string; + price_amount: number | null; + product_id: string; + quantity: number; + updated_at: string; + variant_id: string; + }; + Insert: { + created_at?: string; + id: string; + order_id: string; + price_amount?: number | null; + product_id: string; + quantity?: number; + updated_at?: string; + variant_id: string; + }; + Update: { + created_at?: string; + id?: string; + order_id?: string; + price_amount?: number | null; + product_id?: string; + quantity?: number; + updated_at?: string; + variant_id?: string; + }; Relationships: [ { - foreignKeyName: "order_items_order_id_fkey" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] + foreignKeyName: "order_items_order_id_fkey"; + columns: ["order_id"]; + isOneToOne: false; + referencedRelation: "orders"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; orders: { Row: { - account_id: string - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - created_at: string - currency: string - id: string - status: Database["public"]["Enums"]["payment_status"] - total_amount: number - updated_at: string - } - Insert: { - account_id: string - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - created_at?: string - currency: string - id: string - status: Database["public"]["Enums"]["payment_status"] - total_amount: number - updated_at?: string - } - Update: { - account_id?: string - billing_customer_id?: number - billing_provider?: Database["public"]["Enums"]["billing_provider"] - created_at?: string - currency?: string - id?: string - status?: Database["public"]["Enums"]["payment_status"] - total_amount?: number - updated_at?: string - } + account_id: string; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + created_at: string; + currency: string; + id: string; + status: Database["public"]["Enums"]["payment_status"]; + total_amount: number; + updated_at: string; + }; + Insert: { + account_id: string; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + created_at?: string; + currency: string; + id: string; + status: Database["public"]["Enums"]["payment_status"]; + total_amount: number; + updated_at?: string; + }; + Update: { + account_id?: string; + billing_customer_id?: number; + billing_provider?: Database["public"]["Enums"]["billing_provider"]; + created_at?: string; + currency?: string; + id?: string; + status?: Database["public"]["Enums"]["payment_status"]; + total_amount?: number; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "orders_billing_customer_id_fkey" - columns: ["billing_customer_id"] - isOneToOne: false - referencedRelation: "billing_customers" - referencedColumns: ["id"] + foreignKeyName: "orders_billing_customer_id_fkey"; + columns: ["billing_customer_id"]; + isOneToOne: false; + referencedRelation: "billing_customers"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; organization_domains: { Row: { - created_at: string | null - domain: string - id: string - organization_id: string - } - Insert: { - created_at?: string | null - domain: string - id?: string - organization_id: string - } - Update: { - created_at?: string | null - domain?: string - id?: string - organization_id?: string - } + created_at: string | null; + domain: string; + id: string; + organization_id: string; + }; + Insert: { + created_at?: string | null; + domain: string; + id?: string; + organization_id: string; + }; + Update: { + created_at?: string | null; + domain?: string; + id?: string; + organization_id?: string; + }; Relationships: [ { - foreignKeyName: "organization_domains_organization_id_fkey" - columns: ["organization_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "organization_domains_organization_id_fkey"; + columns: ["organization_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; plans: { Row: { - name: string - tokens_quota: number - variant_id: string - } - Insert: { - name: string - tokens_quota: number - variant_id: string - } - Update: { - name?: string - tokens_quota?: number - variant_id?: string - } - Relationships: [] - } + name: string; + tokens_quota: number; + variant_id: string; + }; + Insert: { + name: string; + tokens_quota: number; + variant_id: string; + }; + Update: { + name?: string; + tokens_quota?: number; + variant_id?: string; + }; + Relationships: []; + }; popup_open: { Row: { - campaignId: string | null - clientId: string | null - fanId: string | null - game: string | null - id: string | null - timestamp: string | null - } - Insert: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: string | null - } - Update: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: string | null - } - Relationships: [] - } + campaignId: string | null; + clientId: string | null; + fanId: string | null; + game: string | null; + id: string | null; + timestamp: string | null; + }; + Insert: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: string | null; + }; + Update: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: string | null; + }; + Relationships: []; + }; post_comments: { Row: { - comment: string | null - commented_at: string - id: string - post_id: string | null - social_id: string | null - } - Insert: { - comment?: string | null - commented_at: string - id?: string - post_id?: string | null - social_id?: string | null - } - Update: { - comment?: string | null - commented_at?: string - id?: string - post_id?: string | null - social_id?: string | null - } + comment: string | null; + commented_at: string; + id: string; + post_id: string | null; + social_id: string | null; + }; + Insert: { + comment?: string | null; + commented_at: string; + id?: string; + post_id?: string | null; + social_id?: string | null; + }; + Update: { + comment?: string | null; + commented_at?: string; + id?: string; + post_id?: string | null; + social_id?: string | null; + }; Relationships: [ { - foreignKeyName: "post_comments_post_id_fkey" - columns: ["post_id"] - isOneToOne: false - referencedRelation: "posts" - referencedColumns: ["id"] + foreignKeyName: "post_comments_post_id_fkey"; + columns: ["post_id"]; + isOneToOne: false; + referencedRelation: "posts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "post_comments_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "post_comments_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; posts: { Row: { - id: string - post_url: string - updated_at: string - } - Insert: { - id?: string - post_url: string - updated_at?: string - } - Update: { - id?: string - post_url?: string - updated_at?: string - } - Relationships: [] - } + id: string; + post_url: string; + updated_at: string; + }; + Insert: { + id?: string; + post_url: string; + updated_at?: string; + }; + Update: { + id?: string; + post_url?: string; + updated_at?: string; + }; + Relationships: []; + }; presave: { Row: { - accessToken: string | null - fanId: string | null - "fanId.error.code": string | null - "fanId.error.name": string | null - id: string | null - presaveId: string | null - presaveReleaseDate: string | null - refreshToken: string | null - timestamp: number | null - } - Insert: { - accessToken?: string | null - fanId?: string | null - "fanId.error.code"?: string | null - "fanId.error.name"?: string | null - id?: string | null - presaveId?: string | null - presaveReleaseDate?: string | null - refreshToken?: string | null - timestamp?: number | null - } - Update: { - accessToken?: string | null - fanId?: string | null - "fanId.error.code"?: string | null - "fanId.error.name"?: string | null - id?: string | null - presaveId?: string | null - presaveReleaseDate?: string | null - refreshToken?: string | null - timestamp?: number | null - } - Relationships: [] - } + accessToken: string | null; + fanId: string | null; + "fanId.error.code": string | null; + "fanId.error.name": string | null; + id: string | null; + presaveId: string | null; + presaveReleaseDate: string | null; + refreshToken: string | null; + timestamp: number | null; + }; + Insert: { + accessToken?: string | null; + fanId?: string | null; + "fanId.error.code"?: string | null; + "fanId.error.name"?: string | null; + id?: string | null; + presaveId?: string | null; + presaveReleaseDate?: string | null; + refreshToken?: string | null; + timestamp?: number | null; + }; + Update: { + accessToken?: string | null; + fanId?: string | null; + "fanId.error.code"?: string | null; + "fanId.error.name"?: string | null; + id?: string | null; + presaveId?: string | null; + presaveReleaseDate?: string | null; + refreshToken?: string | null; + timestamp?: number | null; + }; + Relationships: []; + }; pulse_accounts: { Row: { - account_id: string - active: boolean - id: string - } + account_id: string; + active: boolean; + id: string; + }; Insert: { - account_id: string - active?: boolean - id?: string - } + account_id: string; + active?: boolean; + id?: string; + }; Update: { - account_id?: string - active?: boolean - id?: string - } + account_id?: string; + active?: boolean; + id?: string; + }; Relationships: [ { - foreignKeyName: "pulse_accounts_account_id_fkey" - columns: ["account_id"] - isOneToOne: true - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "pulse_accounts_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: true; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; role_permissions: { Row: { - id: number - permission: Database["public"]["Enums"]["app_permissions"] - role: string - } + id: number; + permission: Database["public"]["Enums"]["app_permissions"]; + role: string; + }; Insert: { - id?: number - permission: Database["public"]["Enums"]["app_permissions"] - role: string - } + id?: number; + permission: Database["public"]["Enums"]["app_permissions"]; + role: string; + }; Update: { - id?: number - permission?: Database["public"]["Enums"]["app_permissions"] - role?: string - } + id?: number; + permission?: Database["public"]["Enums"]["app_permissions"]; + role?: string; + }; Relationships: [ { - foreignKeyName: "role_permissions_role_fkey" - columns: ["role"] - isOneToOne: false - referencedRelation: "roles" - referencedColumns: ["name"] + foreignKeyName: "role_permissions_role_fkey"; + columns: ["role"]; + isOneToOne: false; + referencedRelation: "roles"; + referencedColumns: ["name"]; }, - ] - } + ]; + }; roles: { Row: { - hierarchy_level: number - name: string - } + hierarchy_level: number; + name: string; + }; Insert: { - hierarchy_level: number - name: string - } + hierarchy_level: number; + name: string; + }; Update: { - hierarchy_level?: number - name?: string - } - Relationships: [] - } + hierarchy_level?: number; + name?: string; + }; + Relationships: []; + }; room_reports: { Row: { - id: string - report_id: string - room_id: string | null - } + id: string; + report_id: string; + room_id: string | null; + }; Insert: { - id?: string - report_id?: string - room_id?: string | null - } + id?: string; + report_id?: string; + room_id?: string | null; + }; Update: { - id?: string - report_id?: string - room_id?: string | null - } + id?: string; + report_id?: string; + room_id?: string | null; + }; Relationships: [ { - foreignKeyName: "room_reports_report_id_fkey" - columns: ["report_id"] - isOneToOne: false - referencedRelation: "segment_reports" - referencedColumns: ["id"] + foreignKeyName: "room_reports_report_id_fkey"; + columns: ["report_id"]; + isOneToOne: false; + referencedRelation: "segment_reports"; + referencedColumns: ["id"]; }, { - foreignKeyName: "room_reports_room_id_fkey" - columns: ["room_id"] - isOneToOne: false - referencedRelation: "rooms" - referencedColumns: ["id"] + foreignKeyName: "room_reports_room_id_fkey"; + columns: ["room_id"]; + isOneToOne: false; + referencedRelation: "rooms"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; rooms: { Row: { - account_id: string | null - artist_id: string | null - id: string - topic: string | null - updated_at: string - } - Insert: { - account_id?: string | null - artist_id?: string | null - id?: string - topic?: string | null - updated_at?: string - } - Update: { - account_id?: string | null - artist_id?: string | null - id?: string - topic?: string | null - updated_at?: string - } + account_id: string | null; + artist_id: string | null; + id: string; + topic: string | null; + updated_at: string; + }; + Insert: { + account_id?: string | null; + artist_id?: string | null; + id?: string; + topic?: string | null; + updated_at?: string; + }; + Update: { + account_id?: string | null; + artist_id?: string | null; + id?: string; + topic?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "rooms_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "rooms_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; sales_pipeline_customers: { Row: { - activity_count: number | null - assigned_to: string | null - company_size: string | null - competitors: string[] | null - contact_email: string | null - contact_name: string | null - contact_phone: string | null - contacts: Json | null - conversion_stage: string | null - conversion_target_date: string | null - created_at: string | null - current_artists: number - current_mrr: number - custom_fields: Json | null - days_in_stage: number | null - domain: string | null - email: string | null - engagement_health: string | null - expected_close_date: string | null - external_ids: Json | null - id: string - industry: string | null - internal_owner: string | null - last_activity_date: string | null - last_activity_type: string | null - last_contact_date: string - logo_url: string | null - lost_reason: string | null - name: string - next_action: string | null - next_activity_date: string | null - next_activity_type: string | null - notes: string | null - order_index: number | null - organization: string | null - potential_artists: number - potential_mrr: number - priority: string | null - probability: number | null - recoupable_user_id: string | null - source: string | null - stage: string - stage_entered_at: string | null - tags: string[] | null - todos: Json | null - trial_end_date: string | null - trial_start_date: string | null - type: string | null - updated_at: string | null - use_case_type: string | null - website: string | null - weighted_mrr: number | null - win_reason: string | null - } - Insert: { - activity_count?: number | null - assigned_to?: string | null - company_size?: string | null - competitors?: string[] | null - contact_email?: string | null - contact_name?: string | null - contact_phone?: string | null - contacts?: Json | null - conversion_stage?: string | null - conversion_target_date?: string | null - created_at?: string | null - current_artists?: number - current_mrr?: number - custom_fields?: Json | null - days_in_stage?: number | null - domain?: string | null - email?: string | null - engagement_health?: string | null - expected_close_date?: string | null - external_ids?: Json | null - id?: string - industry?: string | null - internal_owner?: string | null - last_activity_date?: string | null - last_activity_type?: string | null - last_contact_date?: string - logo_url?: string | null - lost_reason?: string | null - name: string - next_action?: string | null - next_activity_date?: string | null - next_activity_type?: string | null - notes?: string | null - order_index?: number | null - organization?: string | null - potential_artists?: number - potential_mrr?: number - priority?: string | null - probability?: number | null - recoupable_user_id?: string | null - source?: string | null - stage: string - stage_entered_at?: string | null - tags?: string[] | null - todos?: Json | null - trial_end_date?: string | null - trial_start_date?: string | null - type?: string | null - updated_at?: string | null - use_case_type?: string | null - website?: string | null - weighted_mrr?: number | null - win_reason?: string | null - } - Update: { - activity_count?: number | null - assigned_to?: string | null - company_size?: string | null - competitors?: string[] | null - contact_email?: string | null - contact_name?: string | null - contact_phone?: string | null - contacts?: Json | null - conversion_stage?: string | null - conversion_target_date?: string | null - created_at?: string | null - current_artists?: number - current_mrr?: number - custom_fields?: Json | null - days_in_stage?: number | null - domain?: string | null - email?: string | null - engagement_health?: string | null - expected_close_date?: string | null - external_ids?: Json | null - id?: string - industry?: string | null - internal_owner?: string | null - last_activity_date?: string | null - last_activity_type?: string | null - last_contact_date?: string - logo_url?: string | null - lost_reason?: string | null - name?: string - next_action?: string | null - next_activity_date?: string | null - next_activity_type?: string | null - notes?: string | null - order_index?: number | null - organization?: string | null - potential_artists?: number - potential_mrr?: number - priority?: string | null - probability?: number | null - recoupable_user_id?: string | null - source?: string | null - stage?: string - stage_entered_at?: string | null - tags?: string[] | null - todos?: Json | null - trial_end_date?: string | null - trial_start_date?: string | null - type?: string | null - updated_at?: string | null - use_case_type?: string | null - website?: string | null - weighted_mrr?: number | null - win_reason?: string | null - } - Relationships: [] - } + activity_count: number | null; + assigned_to: string | null; + company_size: string | null; + competitors: string[] | null; + contact_email: string | null; + contact_name: string | null; + contact_phone: string | null; + contacts: Json | null; + conversion_stage: string | null; + conversion_target_date: string | null; + created_at: string | null; + current_artists: number; + current_mrr: number; + custom_fields: Json | null; + days_in_stage: number | null; + domain: string | null; + email: string | null; + engagement_health: string | null; + expected_close_date: string | null; + external_ids: Json | null; + id: string; + industry: string | null; + internal_owner: string | null; + last_activity_date: string | null; + last_activity_type: string | null; + last_contact_date: string; + logo_url: string | null; + lost_reason: string | null; + name: string; + next_action: string | null; + next_activity_date: string | null; + next_activity_type: string | null; + notes: string | null; + order_index: number | null; + organization: string | null; + potential_artists: number; + potential_mrr: number; + priority: string | null; + probability: number | null; + recoupable_user_id: string | null; + source: string | null; + stage: string; + stage_entered_at: string | null; + tags: string[] | null; + todos: Json | null; + trial_end_date: string | null; + trial_start_date: string | null; + type: string | null; + updated_at: string | null; + use_case_type: string | null; + website: string | null; + weighted_mrr: number | null; + win_reason: string | null; + }; + Insert: { + activity_count?: number | null; + assigned_to?: string | null; + company_size?: string | null; + competitors?: string[] | null; + contact_email?: string | null; + contact_name?: string | null; + contact_phone?: string | null; + contacts?: Json | null; + conversion_stage?: string | null; + conversion_target_date?: string | null; + created_at?: string | null; + current_artists?: number; + current_mrr?: number; + custom_fields?: Json | null; + days_in_stage?: number | null; + domain?: string | null; + email?: string | null; + engagement_health?: string | null; + expected_close_date?: string | null; + external_ids?: Json | null; + id?: string; + industry?: string | null; + internal_owner?: string | null; + last_activity_date?: string | null; + last_activity_type?: string | null; + last_contact_date?: string; + logo_url?: string | null; + lost_reason?: string | null; + name: string; + next_action?: string | null; + next_activity_date?: string | null; + next_activity_type?: string | null; + notes?: string | null; + order_index?: number | null; + organization?: string | null; + potential_artists?: number; + potential_mrr?: number; + priority?: string | null; + probability?: number | null; + recoupable_user_id?: string | null; + source?: string | null; + stage: string; + stage_entered_at?: string | null; + tags?: string[] | null; + todos?: Json | null; + trial_end_date?: string | null; + trial_start_date?: string | null; + type?: string | null; + updated_at?: string | null; + use_case_type?: string | null; + website?: string | null; + weighted_mrr?: number | null; + win_reason?: string | null; + }; + Update: { + activity_count?: number | null; + assigned_to?: string | null; + company_size?: string | null; + competitors?: string[] | null; + contact_email?: string | null; + contact_name?: string | null; + contact_phone?: string | null; + contacts?: Json | null; + conversion_stage?: string | null; + conversion_target_date?: string | null; + created_at?: string | null; + current_artists?: number; + current_mrr?: number; + custom_fields?: Json | null; + days_in_stage?: number | null; + domain?: string | null; + email?: string | null; + engagement_health?: string | null; + expected_close_date?: string | null; + external_ids?: Json | null; + id?: string; + industry?: string | null; + internal_owner?: string | null; + last_activity_date?: string | null; + last_activity_type?: string | null; + last_contact_date?: string; + logo_url?: string | null; + lost_reason?: string | null; + name?: string; + next_action?: string | null; + next_activity_date?: string | null; + next_activity_type?: string | null; + notes?: string | null; + order_index?: number | null; + organization?: string | null; + potential_artists?: number; + potential_mrr?: number; + priority?: string | null; + probability?: number | null; + recoupable_user_id?: string | null; + source?: string | null; + stage?: string; + stage_entered_at?: string | null; + tags?: string[] | null; + todos?: Json | null; + trial_end_date?: string | null; + trial_start_date?: string | null; + type?: string | null; + updated_at?: string | null; + use_case_type?: string | null; + website?: string | null; + weighted_mrr?: number | null; + win_reason?: string | null; + }; + Relationships: []; + }; save_track: { Row: { - game: string | null - id: string | null - timestamp: string | null - } - Insert: { - game?: string | null - id?: string | null - timestamp?: string | null - } - Update: { - game?: string | null - id?: string | null - timestamp?: string | null - } - Relationships: [] - } + game: string | null; + id: string | null; + timestamp: string | null; + }; + Insert: { + game?: string | null; + id?: string | null; + timestamp?: string | null; + }; + Update: { + game?: string | null; + id?: string | null; + timestamp?: string | null; + }; + Relationships: []; + }; scheduled_actions: { Row: { - account_id: string - artist_account_id: string - created_at: string | null - enabled: boolean | null - id: string - last_run: string | null - model: string | null - next_run: string | null - prompt: string - schedule: string - title: string - trigger_schedule_id: string | null - updated_at: string | null - } - Insert: { - account_id: string - artist_account_id: string - created_at?: string | null - enabled?: boolean | null - id?: string - last_run?: string | null - model?: string | null - next_run?: string | null - prompt: string - schedule: string - title: string - trigger_schedule_id?: string | null - updated_at?: string | null - } - Update: { - account_id?: string - artist_account_id?: string - created_at?: string | null - enabled?: boolean | null - id?: string - last_run?: string | null - model?: string | null - next_run?: string | null - prompt?: string - schedule?: string - title?: string - trigger_schedule_id?: string | null - updated_at?: string | null - } + account_id: string; + artist_account_id: string; + created_at: string | null; + enabled: boolean | null; + id: string; + last_run: string | null; + model: string | null; + next_run: string | null; + prompt: string; + schedule: string; + title: string; + trigger_schedule_id: string | null; + updated_at: string | null; + }; + Insert: { + account_id: string; + artist_account_id: string; + created_at?: string | null; + enabled?: boolean | null; + id?: string; + last_run?: string | null; + model?: string | null; + next_run?: string | null; + prompt: string; + schedule: string; + title: string; + trigger_schedule_id?: string | null; + updated_at?: string | null; + }; + Update: { + account_id?: string; + artist_account_id?: string; + created_at?: string | null; + enabled?: boolean | null; + id?: string; + last_run?: string | null; + model?: string | null; + next_run?: string | null; + prompt?: string; + schedule?: string; + title?: string; + trigger_schedule_id?: string | null; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "scheduled_actions_account_id_fkey" - columns: ["account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "scheduled_actions_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "scheduled_actions_artist_account_id_fkey" - columns: ["artist_account_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "scheduled_actions_artist_account_id_fkey"; + columns: ["artist_account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; segment_reports: { Row: { - artist_id: string | null - id: string - next_steps: string | null - report: string | null - updated_at: string | null - } - Insert: { - artist_id?: string | null - id?: string - next_steps?: string | null - report?: string | null - updated_at?: string | null - } - Update: { - artist_id?: string | null - id?: string - next_steps?: string | null - report?: string | null - updated_at?: string | null - } + artist_id: string | null; + id: string; + next_steps: string | null; + report: string | null; + updated_at: string | null; + }; + Insert: { + artist_id?: string | null; + id?: string; + next_steps?: string | null; + report?: string | null; + updated_at?: string | null; + }; + Update: { + artist_id?: string | null; + id?: string; + next_steps?: string | null; + report?: string | null; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "segment_reports_artist_id_fkey" - columns: ["artist_id"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "segment_reports_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; segment_rooms: { Row: { - id: string - room_id: string - segment_id: string - updated_at: string - } - Insert: { - id?: string - room_id: string - segment_id: string - updated_at?: string - } - Update: { - id?: string - room_id?: string - segment_id?: string - updated_at?: string - } + id: string; + room_id: string; + segment_id: string; + updated_at: string; + }; + Insert: { + id?: string; + room_id: string; + segment_id: string; + updated_at?: string; + }; + Update: { + id?: string; + room_id?: string; + segment_id?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "segment_rooms_room_id_fkey" - columns: ["room_id"] - isOneToOne: false - referencedRelation: "rooms" - referencedColumns: ["id"] + foreignKeyName: "segment_rooms_room_id_fkey"; + columns: ["room_id"]; + isOneToOne: false; + referencedRelation: "rooms"; + referencedColumns: ["id"]; }, { - foreignKeyName: "segment_rooms_segment_id_fkey" - columns: ["segment_id"] - isOneToOne: false - referencedRelation: "segments" - referencedColumns: ["id"] + foreignKeyName: "segment_rooms_segment_id_fkey"; + columns: ["segment_id"]; + isOneToOne: false; + referencedRelation: "segments"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; segments: { Row: { - id: string - name: string - updated_at: string | null - } - Insert: { - id?: string - name: string - updated_at?: string | null - } - Update: { - id?: string - name?: string - updated_at?: string | null - } - Relationships: [] - } + id: string; + name: string; + updated_at: string | null; + }; + Insert: { + id?: string; + name: string; + updated_at?: string | null; + }; + Update: { + id?: string; + name?: string; + updated_at?: string | null; + }; + Relationships: []; + }; social_fans: { Row: { - artist_social_id: string - created_at: string - fan_social_id: string - id: string - latest_engagement: string | null - latest_engagement_id: string | null - updated_at: string - } - Insert: { - artist_social_id: string - created_at?: string - fan_social_id: string - id?: string - latest_engagement?: string | null - latest_engagement_id?: string | null - updated_at?: string - } - Update: { - artist_social_id?: string - created_at?: string - fan_social_id?: string - id?: string - latest_engagement?: string | null - latest_engagement_id?: string | null - updated_at?: string - } + artist_social_id: string; + created_at: string; + fan_social_id: string; + id: string; + latest_engagement: string | null; + latest_engagement_id: string | null; + updated_at: string; + }; + Insert: { + artist_social_id: string; + created_at?: string; + fan_social_id: string; + id?: string; + latest_engagement?: string | null; + latest_engagement_id?: string | null; + updated_at?: string; + }; + Update: { + artist_social_id?: string; + created_at?: string; + fan_social_id?: string; + id?: string; + latest_engagement?: string | null; + latest_engagement_id?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "social_fans_artist_social_id_fkey" - columns: ["artist_social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "social_fans_artist_social_id_fkey"; + columns: ["artist_social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, { - foreignKeyName: "social_fans_fan_social_id_fkey" - columns: ["fan_social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "social_fans_fan_social_id_fkey"; + columns: ["fan_social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, { - foreignKeyName: "social_fans_latest_engagement_id_fkey" - columns: ["latest_engagement_id"] - isOneToOne: false - referencedRelation: "post_comments" - referencedColumns: ["id"] + foreignKeyName: "social_fans_latest_engagement_id_fkey"; + columns: ["latest_engagement_id"]; + isOneToOne: false; + referencedRelation: "post_comments"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; social_posts: { Row: { - id: string - post_id: string | null - social_id: string | null - updated_at: string | null - } - Insert: { - id?: string - post_id?: string | null - social_id?: string | null - updated_at?: string | null - } - Update: { - id?: string - post_id?: string | null - social_id?: string | null - updated_at?: string | null - } + id: string; + post_id: string | null; + social_id: string | null; + updated_at: string | null; + }; + Insert: { + id?: string; + post_id?: string | null; + social_id?: string | null; + updated_at?: string | null; + }; + Update: { + id?: string; + post_id?: string | null; + social_id?: string | null; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "social_posts_post_id_fkey" - columns: ["post_id"] - isOneToOne: false - referencedRelation: "posts" - referencedColumns: ["id"] + foreignKeyName: "social_posts_post_id_fkey"; + columns: ["post_id"]; + isOneToOne: false; + referencedRelation: "posts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "social_posts_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "social_posts_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; social_spotify_albums: { Row: { - album_id: string | null - id: string - social_id: string | null - updated_at: string - } - Insert: { - album_id?: string | null - id?: string - social_id?: string | null - updated_at?: string - } - Update: { - album_id?: string | null - id?: string - social_id?: string | null - updated_at?: string - } + album_id: string | null; + id: string; + social_id: string | null; + updated_at: string; + }; + Insert: { + album_id?: string | null; + id?: string; + social_id?: string | null; + updated_at?: string; + }; + Update: { + album_id?: string | null; + id?: string; + social_id?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "social_spotify_albums_album_id_fkey" - columns: ["album_id"] - isOneToOne: false - referencedRelation: "spotify_albums" - referencedColumns: ["id"] + foreignKeyName: "social_spotify_albums_album_id_fkey"; + columns: ["album_id"]; + isOneToOne: false; + referencedRelation: "spotify_albums"; + referencedColumns: ["id"]; }, { - foreignKeyName: "social_spotify_albums_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "social_spotify_albums_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; social_spotify_tracks: { Row: { - id: string - social_id: string - track_id: string | null - updated_at: string | null - } - Insert: { - id?: string - social_id?: string - track_id?: string | null - updated_at?: string | null - } - Update: { - id?: string - social_id?: string - track_id?: string | null - updated_at?: string | null - } + id: string; + social_id: string; + track_id: string | null; + updated_at: string | null; + }; + Insert: { + id?: string; + social_id?: string; + track_id?: string | null; + updated_at?: string | null; + }; + Update: { + id?: string; + social_id?: string; + track_id?: string | null; + updated_at?: string | null; + }; Relationships: [ { - foreignKeyName: "social_spotify_tracks_social_id_fkey" - columns: ["social_id"] - isOneToOne: false - referencedRelation: "socials" - referencedColumns: ["id"] + foreignKeyName: "social_spotify_tracks_social_id_fkey"; + columns: ["social_id"]; + isOneToOne: false; + referencedRelation: "socials"; + referencedColumns: ["id"]; }, { - foreignKeyName: "social_spotify_tracks_track_id_fkey" - columns: ["track_id"] - isOneToOne: false - referencedRelation: "spotify_tracks" - referencedColumns: ["id"] + foreignKeyName: "social_spotify_tracks_track_id_fkey"; + columns: ["track_id"]; + isOneToOne: false; + referencedRelation: "spotify_tracks"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; socials: { Row: { - avatar: string | null - bio: string | null - followerCount: number | null - followingCount: number | null - id: string - profile_url: string - region: string | null - updated_at: string - username: string - } - Insert: { - avatar?: string | null - bio?: string | null - followerCount?: number | null - followingCount?: number | null - id?: string - profile_url: string - region?: string | null - updated_at?: string - username: string - } - Update: { - avatar?: string | null - bio?: string | null - followerCount?: number | null - followingCount?: number | null - id?: string - profile_url?: string - region?: string | null - updated_at?: string - username?: string - } - Relationships: [] - } + avatar: string | null; + bio: string | null; + followerCount: number | null; + followingCount: number | null; + id: string; + profile_url: string; + region: string | null; + updated_at: string; + username: string; + }; + Insert: { + avatar?: string | null; + bio?: string | null; + followerCount?: number | null; + followingCount?: number | null; + id?: string; + profile_url: string; + region?: string | null; + updated_at?: string; + username: string; + }; + Update: { + avatar?: string | null; + bio?: string | null; + followerCount?: number | null; + followingCount?: number | null; + id?: string; + profile_url?: string; + region?: string | null; + updated_at?: string; + username?: string; + }; + Relationships: []; + }; song_artists: { Row: { - artist: string - created_at: string - id: string - song: string - updated_at: string - } - Insert: { - artist: string - created_at?: string - id?: string - song: string - updated_at?: string - } - Update: { - artist?: string - created_at?: string - id?: string - song?: string - updated_at?: string - } + artist: string; + created_at: string; + id: string; + song: string; + updated_at: string; + }; + Insert: { + artist: string; + created_at?: string; + id?: string; + song: string; + updated_at?: string; + }; + Update: { + artist?: string; + created_at?: string; + id?: string; + song?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "song_artists_artist_fkey" - columns: ["artist"] - isOneToOne: false - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "song_artists_artist_fkey"; + columns: ["artist"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, { - foreignKeyName: "song_artists_song_fkey" - columns: ["song"] - isOneToOne: false - referencedRelation: "songs" - referencedColumns: ["isrc"] + foreignKeyName: "song_artists_song_fkey"; + columns: ["song"]; + isOneToOne: false; + referencedRelation: "songs"; + referencedColumns: ["isrc"]; }, - ] - } + ]; + }; songs: { Row: { - album: string | null - isrc: string - name: string | null - notes: string | null - updated_at: string - } - Insert: { - album?: string | null - isrc: string - name?: string | null - notes?: string | null - updated_at?: string - } - Update: { - album?: string | null - isrc?: string - name?: string | null - notes?: string | null - updated_at?: string - } - Relationships: [] - } + album: string | null; + isrc: string; + name: string | null; + notes: string | null; + updated_at: string; + }; + Insert: { + album?: string | null; + isrc: string; + name?: string | null; + notes?: string | null; + updated_at?: string; + }; + Update: { + album?: string | null; + isrc?: string; + name?: string | null; + notes?: string | null; + updated_at?: string; + }; + Relationships: []; + }; spotify: { Row: { - clientId: string | null - country: string | null - display_name: string | null - email: string | null - "explicit_content.filter_enabled": string | null - "explicit_content.filter_locked": string | null - "external_urls.spotify": Json | null - fanId: string | null - "fanId.country": string | null - "fanId.display_name": string | null - "fanId.email": string | null - "fanId.explicit_content.filter_enabled": string | null - "fanId.explicit_content.filter_locked": string | null - "fanId.external_urls.spotify": string | null - "fanId.followers.total": string | null - "fanId.href": string | null - "fanId.id": string | null - "fanId.images": string | null - "fanId.isNewFan": string | null - "fanId.playlist": string | null - "fanId.presavedData.clientId": string | null - "fanId.presavedData.country": string | null - "fanId.presavedData.display_name": string | null - "fanId.presavedData.email": string | null - "fanId.presavedData.explicit_content.filter_enabled": string | null - "fanId.presavedData.explicit_content.filter_locked": string | null - "fanId.presavedData.external_urls.spotify": string | null - "fanId.presavedData.followers.total": string | null - "fanId.presavedData.href": string | null - "fanId.presavedData.id": string | null - "fanId.presavedData.images": string | null - "fanId.presavedData.playlist": string | null - "fanId.presavedData.product": string | null - "fanId.presavedData.recentlyPlayed": string | null - "fanId.presavedData.timestamp": string | null - "fanId.presavedData.type": string | null - "fanId.presavedData.uri": string | null - "fanId.product": string | null - "fanId.timestamp": string | null - "fanId.type": string | null - "fanId.uri": string | null - "followers.total": Json | null - game: string | null - href: string | null - id: string | null - images: Json | null - playlist: Json | null - product: string | null - syncId: string | null - timestamp: string | null - type: string | null - uri: string | null - } - Insert: { - clientId?: string | null - country?: string | null - display_name?: string | null - email?: string | null - "explicit_content.filter_enabled"?: string | null - "explicit_content.filter_locked"?: string | null - "external_urls.spotify"?: Json | null - fanId?: string | null - "fanId.country"?: string | null - "fanId.display_name"?: string | null - "fanId.email"?: string | null - "fanId.explicit_content.filter_enabled"?: string | null - "fanId.explicit_content.filter_locked"?: string | null - "fanId.external_urls.spotify"?: string | null - "fanId.followers.total"?: string | null - "fanId.href"?: string | null - "fanId.id"?: string | null - "fanId.images"?: string | null - "fanId.isNewFan"?: string | null - "fanId.playlist"?: string | null - "fanId.presavedData.clientId"?: string | null - "fanId.presavedData.country"?: string | null - "fanId.presavedData.display_name"?: string | null - "fanId.presavedData.email"?: string | null - "fanId.presavedData.explicit_content.filter_enabled"?: string | null - "fanId.presavedData.explicit_content.filter_locked"?: string | null - "fanId.presavedData.external_urls.spotify"?: string | null - "fanId.presavedData.followers.total"?: string | null - "fanId.presavedData.href"?: string | null - "fanId.presavedData.id"?: string | null - "fanId.presavedData.images"?: string | null - "fanId.presavedData.playlist"?: string | null - "fanId.presavedData.product"?: string | null - "fanId.presavedData.recentlyPlayed"?: string | null - "fanId.presavedData.timestamp"?: string | null - "fanId.presavedData.type"?: string | null - "fanId.presavedData.uri"?: string | null - "fanId.product"?: string | null - "fanId.timestamp"?: string | null - "fanId.type"?: string | null - "fanId.uri"?: string | null - "followers.total"?: Json | null - game?: string | null - href?: string | null - id?: string | null - images?: Json | null - playlist?: Json | null - product?: string | null - syncId?: string | null - timestamp?: string | null - type?: string | null - uri?: string | null - } - Update: { - clientId?: string | null - country?: string | null - display_name?: string | null - email?: string | null - "explicit_content.filter_enabled"?: string | null - "explicit_content.filter_locked"?: string | null - "external_urls.spotify"?: Json | null - fanId?: string | null - "fanId.country"?: string | null - "fanId.display_name"?: string | null - "fanId.email"?: string | null - "fanId.explicit_content.filter_enabled"?: string | null - "fanId.explicit_content.filter_locked"?: string | null - "fanId.external_urls.spotify"?: string | null - "fanId.followers.total"?: string | null - "fanId.href"?: string | null - "fanId.id"?: string | null - "fanId.images"?: string | null - "fanId.isNewFan"?: string | null - "fanId.playlist"?: string | null - "fanId.presavedData.clientId"?: string | null - "fanId.presavedData.country"?: string | null - "fanId.presavedData.display_name"?: string | null - "fanId.presavedData.email"?: string | null - "fanId.presavedData.explicit_content.filter_enabled"?: string | null - "fanId.presavedData.explicit_content.filter_locked"?: string | null - "fanId.presavedData.external_urls.spotify"?: string | null - "fanId.presavedData.followers.total"?: string | null - "fanId.presavedData.href"?: string | null - "fanId.presavedData.id"?: string | null - "fanId.presavedData.images"?: string | null - "fanId.presavedData.playlist"?: string | null - "fanId.presavedData.product"?: string | null - "fanId.presavedData.recentlyPlayed"?: string | null - "fanId.presavedData.timestamp"?: string | null - "fanId.presavedData.type"?: string | null - "fanId.presavedData.uri"?: string | null - "fanId.product"?: string | null - "fanId.timestamp"?: string | null - "fanId.type"?: string | null - "fanId.uri"?: string | null - "followers.total"?: Json | null - game?: string | null - href?: string | null - id?: string | null - images?: Json | null - playlist?: Json | null - product?: string | null - syncId?: string | null - timestamp?: string | null - type?: string | null - uri?: string | null - } - Relationships: [] - } + clientId: string | null; + country: string | null; + display_name: string | null; + email: string | null; + "explicit_content.filter_enabled": string | null; + "explicit_content.filter_locked": string | null; + "external_urls.spotify": Json | null; + fanId: string | null; + "fanId.country": string | null; + "fanId.display_name": string | null; + "fanId.email": string | null; + "fanId.explicit_content.filter_enabled": string | null; + "fanId.explicit_content.filter_locked": string | null; + "fanId.external_urls.spotify": string | null; + "fanId.followers.total": string | null; + "fanId.href": string | null; + "fanId.id": string | null; + "fanId.images": string | null; + "fanId.isNewFan": string | null; + "fanId.playlist": string | null; + "fanId.presavedData.clientId": string | null; + "fanId.presavedData.country": string | null; + "fanId.presavedData.display_name": string | null; + "fanId.presavedData.email": string | null; + "fanId.presavedData.explicit_content.filter_enabled": string | null; + "fanId.presavedData.explicit_content.filter_locked": string | null; + "fanId.presavedData.external_urls.spotify": string | null; + "fanId.presavedData.followers.total": string | null; + "fanId.presavedData.href": string | null; + "fanId.presavedData.id": string | null; + "fanId.presavedData.images": string | null; + "fanId.presavedData.playlist": string | null; + "fanId.presavedData.product": string | null; + "fanId.presavedData.recentlyPlayed": string | null; + "fanId.presavedData.timestamp": string | null; + "fanId.presavedData.type": string | null; + "fanId.presavedData.uri": string | null; + "fanId.product": string | null; + "fanId.timestamp": string | null; + "fanId.type": string | null; + "fanId.uri": string | null; + "followers.total": Json | null; + game: string | null; + href: string | null; + id: string | null; + images: Json | null; + playlist: Json | null; + product: string | null; + syncId: string | null; + timestamp: string | null; + type: string | null; + uri: string | null; + }; + Insert: { + clientId?: string | null; + country?: string | null; + display_name?: string | null; + email?: string | null; + "explicit_content.filter_enabled"?: string | null; + "explicit_content.filter_locked"?: string | null; + "external_urls.spotify"?: Json | null; + fanId?: string | null; + "fanId.country"?: string | null; + "fanId.display_name"?: string | null; + "fanId.email"?: string | null; + "fanId.explicit_content.filter_enabled"?: string | null; + "fanId.explicit_content.filter_locked"?: string | null; + "fanId.external_urls.spotify"?: string | null; + "fanId.followers.total"?: string | null; + "fanId.href"?: string | null; + "fanId.id"?: string | null; + "fanId.images"?: string | null; + "fanId.isNewFan"?: string | null; + "fanId.playlist"?: string | null; + "fanId.presavedData.clientId"?: string | null; + "fanId.presavedData.country"?: string | null; + "fanId.presavedData.display_name"?: string | null; + "fanId.presavedData.email"?: string | null; + "fanId.presavedData.explicit_content.filter_enabled"?: string | null; + "fanId.presavedData.explicit_content.filter_locked"?: string | null; + "fanId.presavedData.external_urls.spotify"?: string | null; + "fanId.presavedData.followers.total"?: string | null; + "fanId.presavedData.href"?: string | null; + "fanId.presavedData.id"?: string | null; + "fanId.presavedData.images"?: string | null; + "fanId.presavedData.playlist"?: string | null; + "fanId.presavedData.product"?: string | null; + "fanId.presavedData.recentlyPlayed"?: string | null; + "fanId.presavedData.timestamp"?: string | null; + "fanId.presavedData.type"?: string | null; + "fanId.presavedData.uri"?: string | null; + "fanId.product"?: string | null; + "fanId.timestamp"?: string | null; + "fanId.type"?: string | null; + "fanId.uri"?: string | null; + "followers.total"?: Json | null; + game?: string | null; + href?: string | null; + id?: string | null; + images?: Json | null; + playlist?: Json | null; + product?: string | null; + syncId?: string | null; + timestamp?: string | null; + type?: string | null; + uri?: string | null; + }; + Update: { + clientId?: string | null; + country?: string | null; + display_name?: string | null; + email?: string | null; + "explicit_content.filter_enabled"?: string | null; + "explicit_content.filter_locked"?: string | null; + "external_urls.spotify"?: Json | null; + fanId?: string | null; + "fanId.country"?: string | null; + "fanId.display_name"?: string | null; + "fanId.email"?: string | null; + "fanId.explicit_content.filter_enabled"?: string | null; + "fanId.explicit_content.filter_locked"?: string | null; + "fanId.external_urls.spotify"?: string | null; + "fanId.followers.total"?: string | null; + "fanId.href"?: string | null; + "fanId.id"?: string | null; + "fanId.images"?: string | null; + "fanId.isNewFan"?: string | null; + "fanId.playlist"?: string | null; + "fanId.presavedData.clientId"?: string | null; + "fanId.presavedData.country"?: string | null; + "fanId.presavedData.display_name"?: string | null; + "fanId.presavedData.email"?: string | null; + "fanId.presavedData.explicit_content.filter_enabled"?: string | null; + "fanId.presavedData.explicit_content.filter_locked"?: string | null; + "fanId.presavedData.external_urls.spotify"?: string | null; + "fanId.presavedData.followers.total"?: string | null; + "fanId.presavedData.href"?: string | null; + "fanId.presavedData.id"?: string | null; + "fanId.presavedData.images"?: string | null; + "fanId.presavedData.playlist"?: string | null; + "fanId.presavedData.product"?: string | null; + "fanId.presavedData.recentlyPlayed"?: string | null; + "fanId.presavedData.timestamp"?: string | null; + "fanId.presavedData.type"?: string | null; + "fanId.presavedData.uri"?: string | null; + "fanId.product"?: string | null; + "fanId.timestamp"?: string | null; + "fanId.type"?: string | null; + "fanId.uri"?: string | null; + "followers.total"?: Json | null; + game?: string | null; + href?: string | null; + id?: string | null; + images?: Json | null; + playlist?: Json | null; + product?: string | null; + syncId?: string | null; + timestamp?: string | null; + type?: string | null; + uri?: string | null; + }; + Relationships: []; + }; spotify_albums: { Row: { - id: string - name: string | null - release_date: string | null - updated_at: string - uri: string - } - Insert: { - id?: string - name?: string | null - release_date?: string | null - updated_at?: string - uri: string - } - Update: { - id?: string - name?: string | null - release_date?: string | null - updated_at?: string - uri?: string - } - Relationships: [] - } + id: string; + name: string | null; + release_date: string | null; + updated_at: string; + uri: string; + }; + Insert: { + id?: string; + name?: string | null; + release_date?: string | null; + updated_at?: string; + uri: string; + }; + Update: { + id?: string; + name?: string | null; + release_date?: string | null; + updated_at?: string; + uri?: string; + }; + Relationships: []; + }; spotify_analytics_albums: { Row: { - analysis_id: string | null - artist_name: string | null - created_at: string - id: string - name: string | null - release_date: number | null - uri: string | null - } - Insert: { - analysis_id?: string | null - artist_name?: string | null - created_at?: string - id?: string - name?: string | null - release_date?: number | null - uri?: string | null - } - Update: { - analysis_id?: string | null - artist_name?: string | null - created_at?: string - id?: string - name?: string | null - release_date?: number | null - uri?: string | null - } + analysis_id: string | null; + artist_name: string | null; + created_at: string; + id: string; + name: string | null; + release_date: number | null; + uri: string | null; + }; + Insert: { + analysis_id?: string | null; + artist_name?: string | null; + created_at?: string; + id?: string; + name?: string | null; + release_date?: number | null; + uri?: string | null; + }; + Update: { + analysis_id?: string | null; + artist_name?: string | null; + created_at?: string; + id?: string; + name?: string | null; + release_date?: number | null; + uri?: string | null; + }; Relationships: [ { - foreignKeyName: "spotify_analytics_albums_analysis_id_fkey" - columns: ["analysis_id"] - isOneToOne: false - referencedRelation: "funnel_analytics" - referencedColumns: ["id"] + foreignKeyName: "spotify_analytics_albums_analysis_id_fkey"; + columns: ["analysis_id"]; + isOneToOne: false; + referencedRelation: "funnel_analytics"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; spotify_analytics_tracks: { Row: { - analysis_id: string | null - artist_name: string | null - created_at: string - id: string - name: string | null - popularity: number | null - uri: string | null - } - Insert: { - analysis_id?: string | null - artist_name?: string | null - created_at?: string - id?: string - name?: string | null - popularity?: number | null - uri?: string | null - } - Update: { - analysis_id?: string | null - artist_name?: string | null - created_at?: string - id?: string - name?: string | null - popularity?: number | null - uri?: string | null - } + analysis_id: string | null; + artist_name: string | null; + created_at: string; + id: string; + name: string | null; + popularity: number | null; + uri: string | null; + }; + Insert: { + analysis_id?: string | null; + artist_name?: string | null; + created_at?: string; + id?: string; + name?: string | null; + popularity?: number | null; + uri?: string | null; + }; + Update: { + analysis_id?: string | null; + artist_name?: string | null; + created_at?: string; + id?: string; + name?: string | null; + popularity?: number | null; + uri?: string | null; + }; Relationships: [ { - foreignKeyName: "spotify_analytics_tracks_analysis_id_fkey" - columns: ["analysis_id"] - isOneToOne: false - referencedRelation: "funnel_analytics" - referencedColumns: ["id"] + foreignKeyName: "spotify_analytics_tracks_analysis_id_fkey"; + columns: ["analysis_id"]; + isOneToOne: false; + referencedRelation: "funnel_analytics"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; spotify_login_button_clicked: { Row: { - campaignId: string | null - clientId: string | null - fanId: string | null - game: string | null - id: string | null - timestamp: number | null - } - Insert: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: number | null - } - Update: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string | null - timestamp?: number | null - } + campaignId: string | null; + clientId: string | null; + fanId: string | null; + game: string | null; + id: string | null; + timestamp: number | null; + }; + Insert: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; + Update: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string | null; + timestamp?: number | null; + }; Relationships: [ { - foreignKeyName: "spotify_login_button_clicked_campaignId_fkey" - columns: ["campaignId"] - isOneToOne: false - referencedRelation: "campaigns" - referencedColumns: ["id"] + foreignKeyName: "spotify_login_button_clicked_campaignId_fkey"; + columns: ["campaignId"]; + isOneToOne: false; + referencedRelation: "campaigns"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; spotify_play_button_clicked: { Row: { - campaignId: string | null - clientId: string | null - fanId: string | null - game: string | null - id: string - isPremium: boolean | null - timestamp: number | null - } - Insert: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string - isPremium?: boolean | null - timestamp?: number | null - } - Update: { - campaignId?: string | null - clientId?: string | null - fanId?: string | null - game?: string | null - id?: string - isPremium?: boolean | null - timestamp?: number | null - } + campaignId: string | null; + clientId: string | null; + fanId: string | null; + game: string | null; + id: string; + isPremium: boolean | null; + timestamp: number | null; + }; + Insert: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string; + isPremium?: boolean | null; + timestamp?: number | null; + }; + Update: { + campaignId?: string | null; + clientId?: string | null; + fanId?: string | null; + game?: string | null; + id?: string; + isPremium?: boolean | null; + timestamp?: number | null; + }; Relationships: [ { - foreignKeyName: "spotify_play_button_clicked_campaignId_fkey" - columns: ["campaignId"] - isOneToOne: false - referencedRelation: "campaigns" - referencedColumns: ["id"] + foreignKeyName: "spotify_play_button_clicked_campaignId_fkey"; + columns: ["campaignId"]; + isOneToOne: false; + referencedRelation: "campaigns"; + referencedColumns: ["id"]; }, { - foreignKeyName: "spotify_play_button_clicked_fanId_fkey" - columns: ["fanId"] - isOneToOne: false - referencedRelation: "fans" - referencedColumns: ["id"] + foreignKeyName: "spotify_play_button_clicked_fanId_fkey"; + columns: ["fanId"]; + isOneToOne: false; + referencedRelation: "fans"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; spotify_tracks: { Row: { - id: string - name: string | null - popularity: number | null - updated_at: string - uri: string - } - Insert: { - id?: string - name?: string | null - popularity?: number | null - updated_at?: string - uri: string - } - Update: { - id?: string - name?: string | null - popularity?: number | null - updated_at?: string - uri?: string - } - Relationships: [] - } + id: string; + name: string | null; + popularity: number | null; + updated_at: string; + uri: string; + }; + Insert: { + id?: string; + name?: string | null; + popularity?: number | null; + updated_at?: string; + uri: string; + }; + Update: { + id?: string; + name?: string | null; + popularity?: number | null; + updated_at?: string; + uri?: string; + }; + Relationships: []; + }; subscription_items: { Row: { - created_at: string - id: string - interval: string - interval_count: number - price_amount: number | null - product_id: string - quantity: number - subscription_id: string - type: Database["public"]["Enums"]["subscription_item_type"] - updated_at: string - variant_id: string - } - Insert: { - created_at?: string - id: string - interval: string - interval_count: number - price_amount?: number | null - product_id: string - quantity?: number - subscription_id: string - type: Database["public"]["Enums"]["subscription_item_type"] - updated_at?: string - variant_id: string - } - Update: { - created_at?: string - id?: string - interval?: string - interval_count?: number - price_amount?: number | null - product_id?: string - quantity?: number - subscription_id?: string - type?: Database["public"]["Enums"]["subscription_item_type"] - updated_at?: string - variant_id?: string - } + created_at: string; + id: string; + interval: string; + interval_count: number; + price_amount: number | null; + product_id: string; + quantity: number; + subscription_id: string; + type: Database["public"]["Enums"]["subscription_item_type"]; + updated_at: string; + variant_id: string; + }; + Insert: { + created_at?: string; + id: string; + interval: string; + interval_count: number; + price_amount?: number | null; + product_id: string; + quantity?: number; + subscription_id: string; + type: Database["public"]["Enums"]["subscription_item_type"]; + updated_at?: string; + variant_id: string; + }; + Update: { + created_at?: string; + id?: string; + interval?: string; + interval_count?: number; + price_amount?: number | null; + product_id?: string; + quantity?: number; + subscription_id?: string; + type?: Database["public"]["Enums"]["subscription_item_type"]; + updated_at?: string; + variant_id?: string; + }; Relationships: [ { - foreignKeyName: "subscription_items_subscription_id_fkey" - columns: ["subscription_id"] - isOneToOne: false - referencedRelation: "subscriptions" - referencedColumns: ["id"] + foreignKeyName: "subscription_items_subscription_id_fkey"; + columns: ["subscription_id"]; + isOneToOne: false; + referencedRelation: "subscriptions"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; subscriptions: { Row: { - account_id: string - active: boolean - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - cancel_at_period_end: boolean - created_at: string - currency: string - id: string - period_ends_at: string - period_starts_at: string - status: Database["public"]["Enums"]["subscription_status"] - trial_ends_at: string | null - trial_starts_at: string | null - updated_at: string - } - Insert: { - account_id: string - active: boolean - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - cancel_at_period_end: boolean - created_at?: string - currency: string - id: string - period_ends_at: string - period_starts_at: string - status: Database["public"]["Enums"]["subscription_status"] - trial_ends_at?: string | null - trial_starts_at?: string | null - updated_at?: string - } - Update: { - account_id?: string - active?: boolean - billing_customer_id?: number - billing_provider?: Database["public"]["Enums"]["billing_provider"] - cancel_at_period_end?: boolean - created_at?: string - currency?: string - id?: string - period_ends_at?: string - period_starts_at?: string - status?: Database["public"]["Enums"]["subscription_status"] - trial_ends_at?: string | null - trial_starts_at?: string | null - updated_at?: string - } + account_id: string; + active: boolean; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + cancel_at_period_end: boolean; + created_at: string; + currency: string; + id: string; + period_ends_at: string; + period_starts_at: string; + status: Database["public"]["Enums"]["subscription_status"]; + trial_ends_at: string | null; + trial_starts_at: string | null; + updated_at: string; + }; + Insert: { + account_id: string; + active: boolean; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + cancel_at_period_end: boolean; + created_at?: string; + currency: string; + id: string; + period_ends_at: string; + period_starts_at: string; + status: Database["public"]["Enums"]["subscription_status"]; + trial_ends_at?: string | null; + trial_starts_at?: string | null; + updated_at?: string; + }; + Update: { + account_id?: string; + active?: boolean; + billing_customer_id?: number; + billing_provider?: Database["public"]["Enums"]["billing_provider"]; + cancel_at_period_end?: boolean; + created_at?: string; + currency?: string; + id?: string; + period_ends_at?: string; + period_starts_at?: string; + status?: Database["public"]["Enums"]["subscription_status"]; + trial_ends_at?: string | null; + trial_starts_at?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "subscriptions_billing_customer_id_fkey" - columns: ["billing_customer_id"] - isOneToOne: false - referencedRelation: "billing_customers" - referencedColumns: ["id"] + foreignKeyName: "subscriptions_billing_customer_id_fkey"; + columns: ["billing_customer_id"]; + isOneToOne: false; + referencedRelation: "billing_customers"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; tasks: { Row: { - account_id: string - created_at: string - description: string | null - done: boolean - id: string - title: string - updated_at: string - } - Insert: { - account_id: string - created_at?: string - description?: string | null - done?: boolean - id?: string - title: string - updated_at?: string - } - Update: { - account_id?: string - created_at?: string - description?: string | null - done?: boolean - id?: string - title?: string - updated_at?: string - } - Relationships: [] - } + account_id: string; + created_at: string; + description: string | null; + done: boolean; + id: string; + title: string; + updated_at: string; + }; + Insert: { + account_id: string; + created_at?: string; + description?: string | null; + done?: boolean; + id?: string; + title: string; + updated_at?: string; + }; + Update: { + account_id?: string; + created_at?: string; + description?: string | null; + done?: boolean; + id?: string; + title?: string; + updated_at?: string; + }; + Relationships: []; + }; test_emails: { Row: { - created_at: string - email: string | null - id: number - } - Insert: { - created_at?: string - email?: string | null - id?: number - } - Update: { - created_at?: string - email?: string | null - id?: number - } - Relationships: [] - } + created_at: string; + email: string | null; + id: number; + }; + Insert: { + created_at?: string; + email?: string | null; + id?: number; + }; + Update: { + created_at?: string; + email?: string | null; + id?: number; + }; + Relationships: []; + }; youtube_tokens: { Row: { - access_token: string - artist_account_id: string - created_at: string - expires_at: string - id: string - refresh_token: string | null - updated_at: string - } - Insert: { - access_token: string - artist_account_id: string - created_at?: string - expires_at: string - id?: string - refresh_token?: string | null - updated_at?: string - } - Update: { - access_token?: string - artist_account_id?: string - created_at?: string - expires_at?: string - id?: string - refresh_token?: string | null - updated_at?: string - } + access_token: string; + artist_account_id: string; + created_at: string; + expires_at: string; + id: string; + refresh_token: string | null; + updated_at: string; + }; + Insert: { + access_token: string; + artist_account_id: string; + created_at?: string; + expires_at: string; + id?: string; + refresh_token?: string | null; + updated_at?: string; + }; + Update: { + access_token?: string; + artist_account_id?: string; + created_at?: string; + expires_at?: string; + id?: string; + refresh_token?: string | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "youtube_tokens_artist_account_id_fkey" - columns: ["artist_account_id"] - isOneToOne: true - referencedRelation: "accounts" - referencedColumns: ["id"] + foreignKeyName: "youtube_tokens_artist_account_id_fkey"; + columns: ["artist_account_id"]; + isOneToOne: true; + referencedRelation: "accounts"; + referencedColumns: ["id"]; }, - ] - } - } + ]; + }; + }; Views: { - [_ in never]: never - } + [_ in never]: never; + }; Functions: { accept_invitation: { - Args: { token: string; user_id: string } - Returns: string - } + Args: { token: string; user_id: string }; + Returns: string; + }; add_invitations_to_account: { Args: { - account_slug: string - invitations: Database["public"]["CompositeTypes"]["invitation"][] - } - Returns: Database["public"]["Tables"]["invitations"]["Row"][] - } + account_slug: string; + invitations: Database["public"]["CompositeTypes"]["invitation"][]; + }; + Returns: Database["public"]["Tables"]["invitations"]["Row"][]; + }; can_action_account_member: { - Args: { target_team_account_id: string; target_user_id: string } - Returns: boolean - } + Args: { target_team_account_id: string; target_user_id: string }; + Returns: boolean; + }; count_reports_by_day: { - Args: { end_date: string; start_date: string } + Args: { end_date: string; start_date: string }; Returns: { - count: number - date_key: string - }[] - } + count: number; + date_key: string; + }[]; + }; count_reports_by_month: { - Args: { end_date: string; start_date: string } + Args: { end_date: string; start_date: string }; Returns: { - count: number - date_key: string - }[] - } + count: number; + date_key: string; + }[]; + }; count_reports_by_week: { - Args: { end_date: string; start_date: string } + Args: { end_date: string; start_date: string }; Returns: { - count: number - date_key: string - }[] - } + count: number; + date_key: string; + }[]; + }; create_invitation: { - Args: { account_id: string; email: string; role: string } + Args: { account_id: string; email: string; role: string }; Returns: { - account_id: string - created_at: string - email: string - expires_at: string - id: number - invite_token: string - invited_by: string - role: string - updated_at: string - } + account_id: string; + created_at: string; + email: string; + expires_at: string; + id: number; + invite_token: string; + invited_by: string; + role: string; + updated_at: string; + }; SetofOptions: { - from: "*" - to: "invitations" - isOneToOne: true - isSetofReturn: false - } - } + from: "*"; + to: "invitations"; + isOneToOne: true; + isSetofReturn: false; + }; + }; deduct_credits: { - Args: { account_id: string; amount: number } - Returns: undefined - } - extract_domain: { Args: { email: string }; Returns: string } + Args: { account_id: string; amount: number }; + Returns: undefined; + }; + extract_domain: { Args: { email: string }; Returns: string }; get_account_invitations: { - Args: { account_slug: string } + Args: { account_slug: string }; Returns: { - account_id: string - created_at: string - email: string - expires_at: string - id: number - invited_by: string - inviter_email: string - inviter_name: string - role: string - updated_at: string - }[] - } + account_id: string; + created_at: string; + email: string; + expires_at: string; + id: number; + invited_by: string; + inviter_email: string; + inviter_name: string; + role: string; + updated_at: string; + }[]; + }; get_account_members: { - Args: { account_slug: string } + Args: { account_slug: string }; Returns: { - account_id: string - created_at: string - email: string - id: string - name: string - picture_url: string - primary_owner_user_id: string - role: string - role_hierarchy_level: number - updated_at: string - user_id: string - }[] - } + account_id: string; + created_at: string; + email: string; + id: string; + name: string; + picture_url: string; + primary_owner_user_id: string; + role: string; + role_hierarchy_level: number; + updated_at: string; + user_id: string; + }[]; + }; get_campaign: | { Args: { clientid: string }; Returns: Json } | { - Args: { artistid: string; campaignid: string; email: string } - Returns: Json - } + Args: { artistid: string; campaignid: string; email: string }; + Returns: Json; + }; get_campaign_fans: { - Args: { artistid: string; email: string } - Returns: Json - } - get_config: { Args: never; Returns: Json } + Args: { artistid: string; email: string }; + Returns: Json; + }; + get_config: { Args: never; Returns: Json }; get_fans_listening_top_songs: { - Args: { artistid: string; email: string } - Returns: Json - } + Args: { artistid: string; email: string }; + Returns: Json; + }; get_message_counts_by_user: | { - Args: { start_date: string } + Args: { start_date: string }; Returns: { - account_email: string - message_count: number - }[] + account_email: string; + message_count: number; + }[]; } | { - Args: { end_date: string; start_date: string } + Args: { end_date: string; start_date: string }; Returns: { - account_email: string - message_count: number - }[] - } + account_email: string; + message_count: number; + }[]; + }; get_rooms_created_by_user: { - Args: { start_date: string } + Args: { start_date: string }; Returns: { - account_email: string - rooms_created: number - }[] - } + account_email: string; + rooms_created: number; + }[]; + }; get_segment_reports_by_user: { - Args: { start_date: string } + Args: { start_date: string }; Returns: { - email: string - segment_report_count: number - }[] - } - get_upper_system_role: { Args: never; Returns: string } + email: string; + segment_report_count: number; + }[]; + }; + get_upper_system_role: { Args: never; Returns: string }; has_active_subscription: { - Args: { target_account_id: string } - Returns: boolean - } - has_credits: { Args: { account_id: string }; Returns: boolean } + Args: { target_account_id: string }; + Returns: boolean; + }; + has_credits: { Args: { account_id: string }; Returns: boolean }; has_more_elevated_role: { Args: { - role_name: string - target_account_id: string - target_user_id: string - } - Returns: boolean - } + role_name: string; + target_account_id: string; + target_user_id: string; + }; + Returns: boolean; + }; has_permission: { Args: { - account_id: string - permission_name: Database["public"]["Enums"]["app_permissions"] - user_id: string - } - Returns: boolean - } + account_id: string; + permission_name: Database["public"]["Enums"]["app_permissions"]; + user_id: string; + }; + Returns: boolean; + }; has_role_on_account: { - Args: { account_id: string; account_role?: string } - Returns: boolean - } + Args: { account_id: string; account_role?: string }; + Returns: boolean; + }; has_same_role_hierarchy_level: { Args: { - role_name: string - target_account_id: string - target_user_id: string - } - Returns: boolean - } - is_account_owner: { Args: { account_id: string }; Returns: boolean } + role_name: string; + target_account_id: string; + target_user_id: string; + }; + Returns: boolean; + }; + is_account_owner: { Args: { account_id: string }; Returns: boolean }; is_account_team_member: { - Args: { target_account_id: string } - Returns: boolean - } - is_set: { Args: { field_name: string }; Returns: boolean } + Args: { target_account_id: string }; + Returns: boolean; + }; + is_set: { Args: { field_name: string }; Returns: boolean }; is_team_member: { - Args: { account_id: string; user_id: string } - Returns: boolean - } + Args: { account_id: string; user_id: string }; + Returns: boolean; + }; team_account_workspace: { - Args: { account_slug: string } + Args: { account_slug: string }; Returns: { - id: string - name: string - permissions: Database["public"]["Enums"]["app_permissions"][] - picture_url: string - primary_owner_user_id: string - role: string - role_hierarchy_level: number - slug: string - subscription_status: Database["public"]["Enums"]["subscription_status"] - }[] - } + id: string; + name: string; + permissions: Database["public"]["Enums"]["app_permissions"][]; + picture_url: string; + primary_owner_user_id: string; + role: string; + role_hierarchy_level: number; + slug: string; + subscription_status: Database["public"]["Enums"]["subscription_status"]; + }[]; + }; transfer_team_account_ownership: { - Args: { new_owner_id: string; target_account_id: string } - Returns: undefined - } + Args: { new_owner_id: string; target_account_id: string }; + Returns: undefined; + }; upsert_order: { Args: { - billing_provider: Database["public"]["Enums"]["billing_provider"] - currency: string - line_items: Json - status: Database["public"]["Enums"]["payment_status"] - target_account_id: string - target_customer_id: string - target_order_id: string - total_amount: number - } + billing_provider: Database["public"]["Enums"]["billing_provider"]; + currency: string; + line_items: Json; + status: Database["public"]["Enums"]["payment_status"]; + target_account_id: string; + target_customer_id: string; + target_order_id: string; + total_amount: number; + }; Returns: { - account_id: string - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - created_at: string - currency: string - id: string - status: Database["public"]["Enums"]["payment_status"] - total_amount: number - updated_at: string - } + account_id: string; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + created_at: string; + currency: string; + id: string; + status: Database["public"]["Enums"]["payment_status"]; + total_amount: number; + updated_at: string; + }; SetofOptions: { - from: "*" - to: "orders" - isOneToOne: true - isSetofReturn: false - } - } + from: "*"; + to: "orders"; + isOneToOne: true; + isSetofReturn: false; + }; + }; upsert_subscription: { Args: { - active: boolean - billing_provider: Database["public"]["Enums"]["billing_provider"] - cancel_at_period_end: boolean - currency: string - line_items: Json - period_ends_at: string - period_starts_at: string - status: Database["public"]["Enums"]["subscription_status"] - target_account_id: string - target_customer_id: string - target_subscription_id: string - trial_ends_at?: string - trial_starts_at?: string - } + active: boolean; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + cancel_at_period_end: boolean; + currency: string; + line_items: Json; + period_ends_at: string; + period_starts_at: string; + status: Database["public"]["Enums"]["subscription_status"]; + target_account_id: string; + target_customer_id: string; + target_subscription_id: string; + trial_ends_at?: string; + trial_starts_at?: string; + }; Returns: { - account_id: string - active: boolean - billing_customer_id: number - billing_provider: Database["public"]["Enums"]["billing_provider"] - cancel_at_period_end: boolean - created_at: string - currency: string - id: string - period_ends_at: string - period_starts_at: string - status: Database["public"]["Enums"]["subscription_status"] - trial_ends_at: string | null - trial_starts_at: string | null - updated_at: string - } + account_id: string; + active: boolean; + billing_customer_id: number; + billing_provider: Database["public"]["Enums"]["billing_provider"]; + cancel_at_period_end: boolean; + created_at: string; + currency: string; + id: string; + period_ends_at: string; + period_starts_at: string; + status: Database["public"]["Enums"]["subscription_status"]; + trial_ends_at: string | null; + trial_starts_at: string | null; + updated_at: string; + }; SetofOptions: { - from: "*" - to: "subscriptions" - isOneToOne: true - isSetofReturn: false - } - } - } + from: "*"; + to: "subscriptions"; + isOneToOne: true; + isSetofReturn: false; + }; + }; + }; Enums: { app_permissions: | "roles.manage" @@ -3817,20 +3846,14 @@ export type Database = { | "members.manage" | "invites.manage" | "tasks.write" - | "tasks.delete" - billing_provider: "stripe" | "lemon-squeezy" | "paddle" - chat_role: "user" | "assistant" - notification_channel: "in_app" | "email" - notification_type: "info" | "warning" | "error" - payment_status: "pending" | "succeeded" | "failed" - social_type: - | "TIKTOK" - | "YOUTUBE" - | "INSTAGRAM" - | "TWITTER" - | "SPOTIFY" - | "APPLE" - subscription_item_type: "flat" | "per_seat" | "metered" + | "tasks.delete"; + billing_provider: "stripe" | "lemon-squeezy" | "paddle"; + chat_role: "user" | "assistant"; + notification_channel: "in_app" | "email"; + notification_type: "info" | "warning" | "error"; + payment_status: "pending" | "succeeded" | "failed"; + social_type: "TIKTOK" | "YOUTUBE" | "INSTAGRAM" | "TWITTER" | "SPOTIFY" | "APPLE"; + subscription_item_type: "flat" | "per_seat" | "metered"; subscription_status: | "active" | "trialing" @@ -3839,133 +3862,131 @@ export type Database = { | "unpaid" | "incomplete" | "incomplete_expired" - | "paused" - } + | "paused"; + }; CompositeTypes: { invitation: { - email: string | null - role: string | null - } - } - } -} + email: string | null; + role: string | null; + }; + }; + }; +}; -type DatabaseWithoutInternals = Omit +type DatabaseWithoutInternals = Omit; -type DefaultSchema = DatabaseWithoutInternals[Extract] +type DefaultSchema = DatabaseWithoutInternals[Extract]; export type Tables< DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R + Row: infer R; } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; } ? R : never - : never + : never; export type TablesInsert< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I + Insert: infer I; } ? I : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I + Insert: infer I; } ? I : never - : never + : never; export type TablesUpdate< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U + Update: infer U; } ? U : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U + Update: infer U; } ? U : never - : never + : never; export type Enums< DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, > = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never + : never; export type CompositeTypes< PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, > = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + : never; export const Constants = { public: { @@ -3984,14 +4005,7 @@ export const Constants = { notification_channel: ["in_app", "email"], notification_type: ["info", "warning", "error"], payment_status: ["pending", "succeeded", "failed"], - social_type: [ - "TIKTOK", - "YOUTUBE", - "INSTAGRAM", - "TWITTER", - "SPOTIFY", - "APPLE", - ], + social_type: ["TIKTOK", "YOUTUBE", "INSTAGRAM", "TWITTER", "SPOTIFY", "APPLE"], subscription_item_type: ["flat", "per_seat", "metered"], subscription_status: [ "active", @@ -4005,4 +4019,4 @@ export const Constants = { ], }, }, -} as const +} as const; From fd74598064c10f3d07dd98a773919247180365bc Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:57:25 -0500 Subject: [PATCH 02/42] feat: [US-003] Create GET /api/artist-connectors endpoint - Add ALLOWED_ARTIST_CONNECTORS constant with 'tiktok' as first connector - Create checkAccountArtistAccess function in Recoup-API (migrated from Recoup-Chat) - Create getArtistConnectors function to return connector status for artists - Create GET /api/artist-connectors endpoint with: - Bearer token and API key auth via validateAuthContext - Artist access validation via checkAccountArtistAccess - Returns list of allowed connectors with connection status Co-Authored-By: Claude Opus 4.5 --- app/api/artist-connectors/route.ts | 78 +++++++++++++++++++ .../ALLOWED_ARTIST_CONNECTORS.ts | 16 ++++ .../artistConnectors/getArtistConnectors.ts | 50 ++++++++++++ .../checkAccountArtistAccess.ts | 63 +++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 app/api/artist-connectors/route.ts create mode 100644 lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts create mode 100644 lib/composio/artistConnectors/getArtistConnectors.ts create mode 100644 lib/supabase/account_artist_ids/checkAccountArtistAccess.ts diff --git a/app/api/artist-connectors/route.ts b/app/api/artist-connectors/route.ts new file mode 100644 index 00000000..5a431708 --- /dev/null +++ b/app/api/artist-connectors/route.ts @@ -0,0 +1,78 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { getArtistConnectors } from "@/lib/composio/artistConnectors/getArtistConnectors"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/artist-connectors + * + * List all available connectors and their connection status for an artist. + * + * Query params: + * - artist_id (required): The artist ID to get connectors for + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * @param request - The incoming request + * @returns List of connectors with connection status + */ +export async function GET(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // Get artist_id from query params + const { searchParams } = new URL(request.url); + const artistId = searchParams.get("artist_id"); + + if (!artistId) { + return NextResponse.json( + { error: "artist_id query parameter is required" }, + { status: 400, headers }, + ); + } + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, artistId); + if (!hasAccess) { + return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); + } + + // Get connectors with status + const connectors = await getArtistConnectors(artistId); + + return NextResponse.json( + { + success: true, + data: { + connectors, + }, + }, + { status: 200, headers }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch artist connectors"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts b/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts new file mode 100644 index 00000000..60201a47 --- /dev/null +++ b/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts @@ -0,0 +1,16 @@ +/** + * List of toolkit slugs that artists are allowed to connect. + * Only these connectors will be shown in the artist-connectors API. + */ +export const ALLOWED_ARTIST_CONNECTORS = ["tiktok"] as const; + +export type AllowedArtistConnector = (typeof ALLOWED_ARTIST_CONNECTORS)[number]; + +/** + * Check if a connector slug is an allowed artist connector. + * + * @param slug + */ +export function isAllowedArtistConnector(slug: string): slug is AllowedArtistConnector { + return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(slug); +} diff --git a/lib/composio/artistConnectors/getArtistConnectors.ts b/lib/composio/artistConnectors/getArtistConnectors.ts new file mode 100644 index 00000000..172f5694 --- /dev/null +++ b/lib/composio/artistConnectors/getArtistConnectors.ts @@ -0,0 +1,50 @@ +import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; +import { ALLOWED_ARTIST_CONNECTORS } from "./ALLOWED_ARTIST_CONNECTORS"; + +/** + * Artist connector info with connection status. + */ +export interface ArtistConnectorInfo { + slug: string; + name: string; + isConnected: boolean; + connectedAccountId?: string; +} + +/** + * Human-readable names for allowed artist connectors. + */ +const CONNECTOR_NAMES: Record = { + tiktok: "TikTok", +}; + +/** + * Get all allowed artist connectors with their connection status. + * + * Returns the list of ALLOWED_ARTIST_CONNECTORS with isConnected status + * based on existing connections in artist_composio_connections table. + * + * @param artistId - The artist ID to get connectors for + * @returns Array of connector info with connection status + */ +export async function getArtistConnectors(artistId: string): Promise { + // Fetch existing connections for this artist + const existingConnections = await selectArtistComposioConnections(artistId); + + // Create a map of toolkit_slug -> connected_account_id for quick lookup + const connectionMap = new Map(); + for (const conn of existingConnections) { + connectionMap.set(conn.toolkit_slug, conn.connected_account_id); + } + + // Build connector list with status + return ALLOWED_ARTIST_CONNECTORS.map(slug => { + const connectedAccountId = connectionMap.get(slug); + return { + slug, + name: CONNECTOR_NAMES[slug] || slug, + isConnected: !!connectedAccountId, + connectedAccountId, + }; + }); +} diff --git a/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts b/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts new file mode 100644 index 00000000..3590eb65 --- /dev/null +++ b/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts @@ -0,0 +1,63 @@ +import supabase from "../serverClient"; + +/** + * Check if an account has access to a specific artist. + * + * Access is granted if: + * 1. Account has direct access via account_artist_ids, OR + * 2. Account and artist share an organization + * + * Fails closed: returns false on any database error to deny access safely. + * + * @param accountId - The account ID to check + * @param artistId - The artist ID to check access for + * @returns true if the account has access to the artist, false otherwise + */ +export async function checkAccountArtistAccess( + accountId: string, + artistId: string, +): Promise { + // 1. Check direct access via account_artist_ids + const { data: directAccess, error: directError } = await supabase + .from("account_artist_ids") + .select("artist_id") + .eq("account_id", accountId) + .eq("artist_id", artistId) + .maybeSingle(); + + if (directError) { + return false; // Fail closed + } + + if (directAccess) return true; + + // 2. Check organization access: user and artist share an org + // Get all orgs the artist belongs to + const { data: artistOrgs, error: artistOrgsError } = await supabase + .from("artist_organization_ids") + .select("organization_id") + .eq("artist_id", artistId); + + if (artistOrgsError) { + return false; // Fail closed + } + + if (!artistOrgs?.length) return false; + + // Check if user belongs to any of those orgs + const orgIds = artistOrgs.map(o => o.organization_id).filter((id): id is string => Boolean(id)); + if (!orgIds.length) return false; + + const { data: userOrgAccess, error: userOrgError } = await supabase + .from("account_organization_ids") + .select("organization_id") + .eq("account_id", accountId) + .in("organization_id", orgIds) + .limit(1); + + if (userOrgError) { + return false; // Fail closed + } + + return !!userOrgAccess?.length; +} From ca25ef5a1d0b0987d5c72dbe06efbbb56b4154e8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:01:19 -0500 Subject: [PATCH 03/42] feat: [US-004] Create POST /api/artist-connectors/authorize endpoint - Add validateAuthorizeArtistConnectorBody.ts with Zod schema for request validation - Add authorizeArtistConnector.ts to generate OAuth URLs via Composio - Add POST /api/artist-connectors/authorize route with auth and access control - Callback URL redirects to /chat?artist_connected={artistId}&toolkit={slug} Co-Authored-By: Claude Opus 4.5 --- app/api/artist-connectors/authorize/route.ts | 92 +++++++++++++++++++ .../authorizeArtistConnector.ts | 58 ++++++++++++ .../validateAuthorizeArtistConnectorBody.ts | 43 +++++++++ 3 files changed, 193 insertions(+) create mode 100644 app/api/artist-connectors/authorize/route.ts create mode 100644 lib/composio/artistConnectors/authorizeArtistConnector.ts create mode 100644 lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts diff --git a/app/api/artist-connectors/authorize/route.ts b/app/api/artist-connectors/authorize/route.ts new file mode 100644 index 00000000..3dcdeec6 --- /dev/null +++ b/app/api/artist-connectors/authorize/route.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { validateAuthorizeArtistConnectorBody } from "@/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody"; +import { authorizeArtistConnector } from "@/lib/composio/artistConnectors/authorizeArtistConnector"; +import { isAllowedArtistConnector } from "@/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/artist-connectors/authorize + * + * Generate an OAuth authorization URL for an artist connector. + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * Request body: + * - artist_id: The artist ID to connect the service for (required) + * - connector: The connector slug, e.g., "tiktok" (required) + * + * @param request - The incoming request + * @returns The redirect URL for OAuth authorization + */ +export async function POST(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // Parse and validate body + const body = await request.json(); + const validated = validateAuthorizeArtistConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { artist_id, connector } = validated; + + // Verify connector is allowed + if (!isAllowedArtistConnector(connector)) { + return NextResponse.json( + { error: `Connector '${connector}' is not allowed for artist connections` }, + { status: 400, headers }, + ); + } + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, artist_id); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + + // Generate OAuth URL + const result = await authorizeArtistConnector(accountId, artist_id, connector); + + return NextResponse.json( + { + success: true, + data: { + connector: result.connector, + redirectUrl: result.redirectUrl, + }, + }, + { status: 200, headers }, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to authorize artist connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/artistConnectors/authorizeArtistConnector.ts b/lib/composio/artistConnectors/authorizeArtistConnector.ts new file mode 100644 index 00000000..cf9dfc4f --- /dev/null +++ b/lib/composio/artistConnectors/authorizeArtistConnector.ts @@ -0,0 +1,58 @@ +import { getComposioClient } from "../client"; +import { getFrontendBaseUrl } from "../getFrontendBaseUrl"; + +/** + * Result of authorizing an artist connector. + */ +export interface AuthorizeArtistConnectorResult { + connector: string; + redirectUrl: string; +} + +/** + * Build callback URL for artist connector OAuth. + * + * Redirects to /chat with artist_connected and toolkit query params + * so the frontend can complete the connection flow. + */ +function buildArtistConnectorCallbackUrl( + artistId: string, + toolkit: string, +): string { + const baseUrl = getFrontendBaseUrl(); + return `${baseUrl}/chat?artist_connected=${artistId}&toolkit=${toolkit}`; +} + +/** + * Generate an OAuth authorization URL for an artist connector. + * + * Why: Used by the /api/artist-connectors/authorize endpoint to let users + * connect external services (like TikTok) for a specific artist. + * + * @param accountId - The user's account ID (used as Composio entity) + * @param artistId - The artist ID to associate the connection with + * @param connector - The connector slug (e.g., "tiktok") + * @returns The redirect URL for OAuth + */ +export async function authorizeArtistConnector( + accountId: string, + artistId: string, + connector: string, +): Promise { + const composio = await getComposioClient(); + + const callbackUrl = buildArtistConnectorCallbackUrl(artistId, connector); + + const session = await composio.create(accountId, { + manageConnections: { + callbackUrl, + }, + }); + + const connectionRequest = await session.authorize(connector); + + return { + connector, + redirectUrl: connectionRequest.redirectUrl, + }; +} diff --git a/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts b/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts new file mode 100644 index 00000000..fa86ac9d --- /dev/null +++ b/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const authorizeArtistConnectorBodySchema = z.object({ + artist_id: z + .string({ message: "artist_id is required" }) + .uuid("artist_id must be a valid UUID"), + connector: z + .string({ message: "connector is required" }) + .min(1, "connector cannot be empty (e.g., 'tiktok')"), +}); + +export type AuthorizeArtistConnectorBody = z.infer< + typeof authorizeArtistConnectorBodySchema +>; + +/** + * Validates request body for POST /api/artist-connectors/authorize. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateAuthorizeArtistConnectorBody( + body: unknown, +): NextResponse | AuthorizeArtistConnectorBody { + const result = authorizeArtistConnectorBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From cceffd44924a66c29053cf43506508a47c30be87 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:05:25 -0500 Subject: [PATCH 04/42] feat: [US-005] Add DELETE /api/artist-connectors endpoint - Add DELETE handler to disconnect an artist's connector from Composio - Create validateDisconnectArtistConnectorBody.ts with Zod schema - Create verifyArtistConnectorOwnership.ts to check connection ownership - Create disconnectArtistConnector.ts to remove from Composio and DB Co-Authored-By: Claude Opus 4.5 --- app/api/artist-connectors/route.ts | 66 +++++++++++++++++++ .../disconnectArtistConnector.ts | 53 +++++++++++++++ .../validateDisconnectArtistConnectorBody.ts | 37 +++++++++++ .../verifyArtistConnectorOwnership.ts | 22 +++++++ 4 files changed, 178 insertions(+) create mode 100644 lib/composio/artistConnectors/disconnectArtistConnector.ts create mode 100644 lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts create mode 100644 lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts diff --git a/app/api/artist-connectors/route.ts b/app/api/artist-connectors/route.ts index 5a431708..63a9b73a 100644 --- a/app/api/artist-connectors/route.ts +++ b/app/api/artist-connectors/route.ts @@ -4,6 +4,9 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { getArtistConnectors } from "@/lib/composio/artistConnectors/getArtistConnectors"; +import { validateDisconnectArtistConnectorBody } from "@/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody"; +import { verifyArtistConnectorOwnership } from "@/lib/composio/artistConnectors/verifyArtistConnectorOwnership"; +import { disconnectArtistConnector } from "@/lib/composio/artistConnectors/disconnectArtistConnector"; /** * OPTIONS handler for CORS preflight requests. @@ -76,3 +79,66 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: message }, { status: 500, headers }); } } + +/** + * DELETE /api/artist-connectors + * + * Disconnect an artist's connector from Composio and remove the connection record. + * + * Body: + * - artist_id (required): The artist ID + * - connected_account_id (required): The connected account ID to disconnect + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * @param request - The incoming request + * @returns Success status + */ +export async function DELETE(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // Parse and validate body + const body = await request.json(); + const validated = validateDisconnectArtistConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { artist_id, connected_account_id } = validated; + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, artist_id); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + + // Verify the connected account belongs to this artist + const isOwner = await verifyArtistConnectorOwnership(artist_id, connected_account_id); + if (!isOwner) { + return NextResponse.json( + { error: "Connected account not found or does not belong to this artist" }, + { status: 403, headers }, + ); + } + + // Disconnect from Composio and remove from DB + await disconnectArtistConnector(artist_id, connected_account_id); + + return NextResponse.json({ success: true }, { status: 200, headers }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to disconnect artist connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/artistConnectors/disconnectArtistConnector.ts b/lib/composio/artistConnectors/disconnectArtistConnector.ts new file mode 100644 index 00000000..53746b4a --- /dev/null +++ b/lib/composio/artistConnectors/disconnectArtistConnector.ts @@ -0,0 +1,53 @@ +import { getComposioApiKey } from "../getComposioApiKey"; +import { deleteArtistComposioConnection } from "@/lib/supabase/artist_composio_connections/deleteArtistComposioConnection"; +import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; + +/** + * Disconnect an artist connector from Composio and remove the DB record. + * + * Why: When an artist disconnects a service (like TikTok), we need to: + * 1. Delete the connected account from Composio's side + * 2. Remove the mapping from our artist_composio_connections table + * + * @param artistId - The artist ID + * @param connectedAccountId - The ID of the connected account to disconnect + * @returns Success status + */ +export async function disconnectArtistConnector( + artistId: string, + connectedAccountId: string, +): Promise<{ success: boolean }> { + // First, find the connection record in our DB to get the ID + const connections = await selectArtistComposioConnections(artistId); + const connection = connections.find(c => c.connected_account_id === connectedAccountId); + + if (!connection) { + throw new Error("Connection not found"); + } + + // Delete from Composio using their v3 API + const apiKey = getComposioApiKey(); + const url = `https://backend.composio.dev/api/v3/connected_accounts/${connectedAccountId}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { + "x-api-key": apiKey, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to disconnect from Composio (${response.status}): ${errorText}`); + } + + // Remove from our DB + const { error } = await deleteArtistComposioConnection(connection.id); + + if (error) { + throw new Error(`Failed to remove connection record: ${error.message}`); + } + + return { success: true }; +} diff --git a/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts b/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts new file mode 100644 index 00000000..2d3cebf7 --- /dev/null +++ b/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const disconnectArtistConnectorBodySchema = z.object({ + artist_id: z.string().uuid("artist_id must be a valid UUID"), + connected_account_id: z.string().min(1, "connected_account_id is required"), +}); + +export type DisconnectArtistConnectorBody = z.infer; + +/** + * Validates request body for DELETE /api/artist-connectors. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateDisconnectArtistConnectorBody( + body: unknown, +): NextResponse | DisconnectArtistConnectorBody { + const result = disconnectArtistConnectorBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts b/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts new file mode 100644 index 00000000..99b02b61 --- /dev/null +++ b/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts @@ -0,0 +1,22 @@ +import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; + +/** + * Verifies that a connected account ID belongs to the specified artist. + * + * Why: Before disconnecting an artist connector, we must verify that the + * connected account actually belongs to this artist to prevent users from + * disconnecting connections from other artists they may not own. + * + * @param artistId - The artist ID + * @param connectedAccountId - The connected account ID to verify + * @returns true if the connected account belongs to the artist, false otherwise + */ +export async function verifyArtistConnectorOwnership( + artistId: string, + connectedAccountId: string, +): Promise { + const connections = await selectArtistComposioConnections(artistId); + + // Check if any of the artist's connections match this connected account ID + return connections.some(connection => connection.connected_account_id === connectedAccountId); +} From 5445abb0023b6971955cfafb5f4c2d1f996d741e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:07:04 -0500 Subject: [PATCH 05/42] feat: [US-006] Add artist-connectors callback URL destination - Add 'artist-connectors' to CallbackDestination type in getCallbackUrl.ts - Add artistId and toolkit to CallbackOptions interface - Handle artist-connectors destination returning /chat?artist_connected={artistId}&toolkit={toolkit} - Update authorizeArtistConnector.ts to use getCallbackUrl instead of local function Co-Authored-By: Claude Opus 4.5 --- .../authorizeArtistConnector.ts | 22 +++++-------------- lib/composio/getCallbackUrl.ts | 14 +++++++++--- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/lib/composio/artistConnectors/authorizeArtistConnector.ts b/lib/composio/artistConnectors/authorizeArtistConnector.ts index cf9dfc4f..8b95d409 100644 --- a/lib/composio/artistConnectors/authorizeArtistConnector.ts +++ b/lib/composio/artistConnectors/authorizeArtistConnector.ts @@ -1,5 +1,5 @@ import { getComposioClient } from "../client"; -import { getFrontendBaseUrl } from "../getFrontendBaseUrl"; +import { getCallbackUrl } from "../getCallbackUrl"; /** * Result of authorizing an artist connector. @@ -9,20 +9,6 @@ export interface AuthorizeArtistConnectorResult { redirectUrl: string; } -/** - * Build callback URL for artist connector OAuth. - * - * Redirects to /chat with artist_connected and toolkit query params - * so the frontend can complete the connection flow. - */ -function buildArtistConnectorCallbackUrl( - artistId: string, - toolkit: string, -): string { - const baseUrl = getFrontendBaseUrl(); - return `${baseUrl}/chat?artist_connected=${artistId}&toolkit=${toolkit}`; -} - /** * Generate an OAuth authorization URL for an artist connector. * @@ -41,7 +27,11 @@ export async function authorizeArtistConnector( ): Promise { const composio = await getComposioClient(); - const callbackUrl = buildArtistConnectorCallbackUrl(artistId, connector); + const callbackUrl = getCallbackUrl({ + destination: "artist-connectors", + artistId, + toolkit: connector, + }); const session = await composio.create(accountId, { manageConnections: { diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index 570c9251..c1eaaaef 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -4,21 +4,25 @@ import { getFrontendBaseUrl } from "./getFrontendBaseUrl"; * Build OAuth callback URL based on environment and destination. * * Why: Composio redirects users back after OAuth. We need different - * destinations depending on context (chat room vs settings page). + * destinations depending on context (chat room vs settings page vs artist connections). */ -type CallbackDestination = "chat" | "connectors"; +type CallbackDestination = "chat" | "connectors" | "artist-connectors"; interface CallbackOptions { destination: CallbackDestination; roomId?: string; + artistId?: string; + toolkit?: string; } /** * Build callback URL for OAuth redirects. * - * @param options.destination - Where to redirect: "chat" or "connectors" + * @param options.destination - Where to redirect: "chat", "connectors", or "artist-connectors" * @param options.roomId - For chat destination, the room ID to return to + * @param options.artistId - For artist-connectors destination, the artist ID + * @param options.toolkit - For artist-connectors destination, the toolkit slug * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { @@ -28,6 +32,10 @@ export function getCallbackUrl(options: CallbackOptions): string { return `${baseUrl}/settings/connectors?connected=true`; } + if (options.destination === "artist-connectors") { + return `${baseUrl}/chat?artist_connected=${options.artistId}&toolkit=${options.toolkit}`; + } + // Chat destination const path = options.roomId ? `/chat/${options.roomId}` : "/chat"; return `${baseUrl}${path}?connected=true`; From ac6da1d5360b90f295c27670a3318e4614f3fae3 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:08:55 -0500 Subject: [PATCH 06/42] feat: [US-007] Add TikTok to enabled toolkits Add 'tiktok' to the ENABLED_TOOLKITS array so TikTok tools are available in Tool Router sessions, enabling the LLM to access TikTok data. Co-Authored-By: Claude Opus 4.5 --- lib/composio/toolRouter/createSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts index f5fad9b1..05446e39 100644 --- a/lib/composio/toolRouter/createSession.ts +++ b/lib/composio/toolRouter/createSession.ts @@ -5,7 +5,7 @@ import { getCallbackUrl } from "../getCallbackUrl"; * Toolkits available in Tool Router sessions. * Add more toolkits here as we expand Composio integration. */ -const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs"]; +const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"]; /** * Create a Composio Tool Router session for a user. From b276a7a69f8556e4fc4b6c883b7eba6a59e17568 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:15:40 -0500 Subject: [PATCH 07/42] feat: [US-008] Modify createSession to accept connectedAccounts - Add artistConnections parameter to createToolRouterSession (Record | undefined) - Pass connectedAccounts option to composio.create() call - Update getComposioTools to accept and pass artistConnections parameter - Add JSDoc documentation for the new parameter This enables artist-specific Composio connections to be used when creating Tool Router sessions, allowing the LLM to use the correct TikTok account for the selected artist. Co-Authored-By: Claude Opus 4.5 --- lib/composio/toolRouter/createSession.ts | 11 ++++++++++- lib/composio/toolRouter/getTools.ts | 6 ++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts index 05446e39..ac774383 100644 --- a/lib/composio/toolRouter/createSession.ts +++ b/lib/composio/toolRouter/createSession.ts @@ -9,8 +9,16 @@ const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"] /** * Create a Composio Tool Router session for a user. + * + * @param userId - Unique identifier for the user (accountId) + * @param roomId - Optional chat room ID for OAuth redirect + * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections */ -export async function createToolRouterSession(userId: string, roomId?: string) { +export async function createToolRouterSession( + userId: string, + roomId?: string, + artistConnections?: Record +) { const composio = await getComposioClient(); const callbackUrl = getCallbackUrl({ @@ -23,6 +31,7 @@ export async function createToolRouterSession(userId: string, roomId?: string) { manageConnections: { callbackUrl, }, + connectedAccounts: artistConnections, }); return session; diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 9855098c..77613de2 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -50,11 +50,13 @@ function isValidTool(tool: unknown): tool is Tool { * * @param userId - Unique identifier for the user (accountId) * @param roomId - Optional chat room ID for OAuth redirect + * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections * @returns ToolSet containing filtered Vercel AI SDK tools */ export async function getComposioTools( userId: string, - roomId?: string + roomId?: string, + artistConnections?: Record ): Promise { // Skip Composio if API key is not configured if (!process.env.COMPOSIO_API_KEY) { @@ -62,7 +64,7 @@ export async function getComposioTools( } try { - const session = await createToolRouterSession(userId, roomId); + const session = await createToolRouterSession(userId, roomId, artistConnections); const allTools = await session.tools(); // Filter to only allowed tools with runtime validation From e3596937957f52aaf447ee0e4eef09b95e8a659b Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:17:41 -0500 Subject: [PATCH 08/42] feat: [US-009] Wire up artistId in chat tool setup - Modified getComposioTools to accept artistId parameter - If artistId provided, fetches artist_composio_connections from DB - Transforms connections to Record format - Passes artistConnections to createToolRouterSession - Modified setupToolsForRequest to extract artistId from body - Passes artistId through to getComposioTools Co-Authored-By: Claude Opus 4.5 --- lib/chat/setupToolsForRequest.ts | 6 +++--- lib/composio/toolRouter/getTools.ts | 21 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 77b1747d..ba586e19 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -8,18 +8,18 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; * Sets up and filters tools for a chat request. * Aggregates tools from: * - MCP server (via HTTP transport to /api/mcp for proper auth) - * - Composio Tool Router (Google Sheets, Google Drive, Google Docs) + * - Composio Tool Router (Google Sheets, Google Drive, Google Docs, TikTok) * * @param body - The chat request body * @returns Filtered tool set ready for use */ export async function setupToolsForRequest(body: ChatRequestBody): Promise { - const { accountId, roomId, excludeTools, authToken } = body; + const { accountId, artistId, roomId, excludeTools, authToken } = body; // Fetch MCP tools and Composio tools in parallel - they're independent const [mcpTools, composioTools] = await Promise.all([ authToken ? getMcpTools(authToken) : Promise.resolve({}), - getComposioTools(accountId, roomId), + getComposioTools(accountId, artistId, roomId), ]); // Merge all tools diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 77613de2..343473ca 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,5 +1,6 @@ import { createToolRouterSession } from "./createSession"; import type { Tool, ToolSet } from "ai"; +import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; /** * Tools we want to expose from Composio Tool Router. @@ -49,14 +50,14 @@ function isValidTool(tool: unknown): tool is Tool { * - @composio packages fail to load (bundler incompatibility) * * @param userId - Unique identifier for the user (accountId) + * @param artistId - Optional artist ID to use artist-specific Composio connections * @param roomId - Optional chat room ID for OAuth redirect - * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections * @returns ToolSet containing filtered Vercel AI SDK tools */ export async function getComposioTools( userId: string, + artistId?: string, roomId?: string, - artistConnections?: Record ): Promise { // Skip Composio if API key is not configured if (!process.env.COMPOSIO_API_KEY) { @@ -64,6 +65,22 @@ export async function getComposioTools( } try { + // Fetch artist-specific Composio connections if artistId is provided + let artistConnections: Record | undefined; + if (artistId) { + const connections = await selectArtistComposioConnections(artistId); + if (connections.length > 0) { + // Transform to { toolkit_slug: connected_account_id } format + artistConnections = connections.reduce( + (acc, conn) => { + acc[conn.toolkit_slug] = conn.connected_account_id; + return acc; + }, + {} as Record, + ); + } + } + const session = await createToolRouterSession(userId, roomId, artistConnections); const allTools = await session.tools(); From e39c59d67891c4b5d1f77e43edd13e82a59e988b Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:50:35 -0500 Subject: [PATCH 09/42] feat: [US-014] Handle OAuth callback with complete endpoint Add POST /api/artist-connectors/complete endpoint to finalize OAuth flow: - Query Composio for the user's connected account after OAuth redirect - Store the connection mapping in artist_composio_connections table - Add Zod validation for request body (artist_id, toolkit_slug) Co-Authored-By: Claude Opus 4.5 --- app/api/artist-connectors/complete/route.ts | 92 +++++++++++++++++++ .../completeArtistConnector.ts | 61 ++++++++++++ .../validateCompleteArtistConnectorBody.ts | 39 ++++++++ 3 files changed, 192 insertions(+) create mode 100644 app/api/artist-connectors/complete/route.ts create mode 100644 lib/composio/artistConnectors/completeArtistConnector.ts create mode 100644 lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts diff --git a/app/api/artist-connectors/complete/route.ts b/app/api/artist-connectors/complete/route.ts new file mode 100644 index 00000000..d661f994 --- /dev/null +++ b/app/api/artist-connectors/complete/route.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { validateCompleteArtistConnectorBody } from "@/lib/composio/artistConnectors/validateCompleteArtistConnectorBody"; +import { completeArtistConnector } from "@/lib/composio/artistConnectors/completeArtistConnector"; +import { isAllowedArtistConnector } from "@/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/artist-connectors/complete + * + * Complete the OAuth flow for an artist connector. + * Queries Composio for the latest connection and stores it in the database. + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * Request body: + * - artist_id: The artist ID to associate the connection with (required) + * - toolkit_slug: The toolkit slug, e.g., "tiktok" (required) + * + * @param request - The incoming request + * @returns Success status and connected account ID + */ +export async function POST(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // Parse and validate body + const body = await request.json(); + const validated = validateCompleteArtistConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { artist_id, toolkit_slug } = validated; + + // Verify toolkit is allowed + if (!isAllowedArtistConnector(toolkit_slug)) { + return NextResponse.json( + { error: `Toolkit '${toolkit_slug}' is not allowed for artist connections` }, + { status: 400, headers }, + ); + } + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, artist_id); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + + // Complete the OAuth flow + const result = await completeArtistConnector(accountId, artist_id, toolkit_slug); + + return NextResponse.json( + { + success: true, + data: { + connectedAccountId: result.connectedAccountId, + }, + }, + { status: 200, headers }, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to complete artist connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/artistConnectors/completeArtistConnector.ts b/lib/composio/artistConnectors/completeArtistConnector.ts new file mode 100644 index 00000000..02166a8b --- /dev/null +++ b/lib/composio/artistConnectors/completeArtistConnector.ts @@ -0,0 +1,61 @@ +import { getComposioClient } from "../client"; +import { insertArtistComposioConnection } from "@/lib/supabase/artist_composio_connections/insertArtistComposioConnection"; + +/** + * Result of completing an artist connector OAuth flow. + */ +export interface CompleteArtistConnectorResult { + success: boolean; + connectedAccountId: string; +} + +/** + * Complete the OAuth flow for an artist connector. + * + * Why: After OAuth redirect, we need to query Composio for the newly + * created connection and store the mapping in our database. + * + * @param accountId - The user's account ID (Composio entity) + * @param artistId - The artist ID to associate the connection with + * @param toolkitSlug - The toolkit slug (e.g., "tiktok") + * @returns The result with the connected account ID + * @throws Error if connection not found or database insert fails + */ +export async function completeArtistConnector( + accountId: string, + artistId: string, + toolkitSlug: string, +): Promise { + const composio = await getComposioClient(); + + // Create a session for the user to query their connections + const session = await composio.create(accountId); + + // Get all toolkits to find the connected one + const toolkits = await session.toolkits(); + + // Find the toolkit matching the slug + const toolkit = toolkits.items.find((t) => t.slug === toolkitSlug); + + if (!toolkit || !toolkit.connection?.isActive || !toolkit.connection?.connectedAccount?.id) { + throw new Error(`No active connection found for toolkit '${toolkitSlug}'`); + } + + const connectedAccountId = toolkit.connection.connectedAccount.id; + + // Store the mapping in our database + const { error } = await insertArtistComposioConnection({ + artist_id: artistId, + toolkit_slug: toolkitSlug, + connected_account_id: connectedAccountId, + }); + + if (error) { + throw new Error("Failed to save connection to database"); + } + + return { + success: true, + connectedAccountId, + }; +} diff --git a/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts b/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts new file mode 100644 index 00000000..ef7d1158 --- /dev/null +++ b/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Schema for the complete artist connector request body. + */ +export const completeArtistConnectorBodySchema = z.object({ + artist_id: z + .string({ message: "artist_id is required" }) + .uuid("artist_id must be a valid UUID"), + toolkit_slug: z + .string({ message: "toolkit_slug is required" }) + .min(1, "toolkit_slug cannot be empty (e.g., 'tiktok')"), +}); + +export type CompleteArtistConnectorBody = z.infer; + +/** + * Validate the complete artist connector request body. + * + * @param body - The request body to validate + * @returns The validated body or a NextResponse with error + */ +export function validateCompleteArtistConnectorBody( + body: unknown, +): CompleteArtistConnectorBody | NextResponse { + const result = completeArtistConnectorBodySchema.safeParse(body); + + if (!result.success) { + const message = result.error.issues.map((i) => i.message).join(", "); + return NextResponse.json( + { error: message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} From 6f51f4064dd968ac51d85f1f3137aedeedd853aa Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:49:40 -0500 Subject: [PATCH 10/42] refactor: use artistId as Composio entity for artist connections - Remove artist_composio_connections table and related code - Remove /complete endpoint (no longer needed) - Use artistId directly as Composio entity when connecting - Query Composio at chat time for artist connections - Pass connections to user session via connectedAccounts Composio is now the source of truth for artist connections. --- app/api/artist-connectors/authorize/route.ts | 4 +- app/api/artist-connectors/complete/route.ts | 92 ------------------- app/api/artist-connectors/route.ts | 12 +-- .../authorizeArtistConnector.ts | 11 +-- .../completeArtistConnector.ts | 61 ------------ .../disconnectArtistConnector.ts | 42 ++++----- .../artistConnectors/getArtistConnectors.ts | 36 +++++--- .../validateCompleteArtistConnectorBody.ts | 39 -------- .../verifyArtistConnectorOwnership.ts | 22 ----- lib/composio/toolRouter/getTools.ts | 57 +++++++++--- .../deleteArtistComposioConnection.ts | 20 ---- .../insertArtistComposioConnection.ts | 27 ------ .../selectArtistComposioConnection.ts | 27 ------ .../selectArtistComposioConnections.ts | 23 ----- types/database.types.ts | 35 ------- 15 files changed, 97 insertions(+), 411 deletions(-) delete mode 100644 app/api/artist-connectors/complete/route.ts delete mode 100644 lib/composio/artistConnectors/completeArtistConnector.ts delete mode 100644 lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts delete mode 100644 lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts delete mode 100644 lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts delete mode 100644 lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts delete mode 100644 lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts delete mode 100644 lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts diff --git a/app/api/artist-connectors/authorize/route.ts b/app/api/artist-connectors/authorize/route.ts index 3dcdeec6..1343c204 100644 --- a/app/api/artist-connectors/authorize/route.ts +++ b/app/api/artist-connectors/authorize/route.ts @@ -71,8 +71,8 @@ export async function POST(request: NextRequest): Promise { ); } - // Generate OAuth URL - const result = await authorizeArtistConnector(accountId, artist_id, connector); + // Generate OAuth URL - uses artistId as Composio entity + const result = await authorizeArtistConnector(artist_id, connector); return NextResponse.json( { diff --git a/app/api/artist-connectors/complete/route.ts b/app/api/artist-connectors/complete/route.ts deleted file mode 100644 index d661f994..00000000 --- a/app/api/artist-connectors/complete/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -import { validateCompleteArtistConnectorBody } from "@/lib/composio/artistConnectors/validateCompleteArtistConnectorBody"; -import { completeArtistConnector } from "@/lib/composio/artistConnectors/completeArtistConnector"; -import { isAllowedArtistConnector } from "@/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns Empty response with CORS headers - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/artist-connectors/complete - * - * Complete the OAuth flow for an artist connector. - * Queries Composio for the latest connection and stores it in the database. - * - * Authentication: x-api-key OR Authorization Bearer token required. - * - * Request body: - * - artist_id: The artist ID to associate the connection with (required) - * - toolkit_slug: The toolkit slug, e.g., "tiktok" (required) - * - * @param request - The incoming request - * @returns Success status and connected account ID - */ -export async function POST(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - // Validate auth - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - // Parse and validate body - const body = await request.json(); - const validated = validateCompleteArtistConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { artist_id, toolkit_slug } = validated; - - // Verify toolkit is allowed - if (!isAllowedArtistConnector(toolkit_slug)) { - return NextResponse.json( - { error: `Toolkit '${toolkit_slug}' is not allowed for artist connections` }, - { status: 400, headers }, - ); - } - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, artist_id); - if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); - } - - // Complete the OAuth flow - const result = await completeArtistConnector(accountId, artist_id, toolkit_slug); - - return NextResponse.json( - { - success: true, - data: { - connectedAccountId: result.connectedAccountId, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to complete artist connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } -} diff --git a/app/api/artist-connectors/route.ts b/app/api/artist-connectors/route.ts index 63a9b73a..af74eabb 100644 --- a/app/api/artist-connectors/route.ts +++ b/app/api/artist-connectors/route.ts @@ -5,7 +5,6 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { getArtistConnectors } from "@/lib/composio/artistConnectors/getArtistConnectors"; import { validateDisconnectArtistConnectorBody } from "@/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody"; -import { verifyArtistConnectorOwnership } from "@/lib/composio/artistConnectors/verifyArtistConnectorOwnership"; import { disconnectArtistConnector } from "@/lib/composio/artistConnectors/disconnectArtistConnector"; /** @@ -124,16 +123,7 @@ export async function DELETE(request: NextRequest): Promise { ); } - // Verify the connected account belongs to this artist - const isOwner = await verifyArtistConnectorOwnership(artist_id, connected_account_id); - if (!isOwner) { - return NextResponse.json( - { error: "Connected account not found or does not belong to this artist" }, - { status: 403, headers }, - ); - } - - // Disconnect from Composio and remove from DB + // Disconnect from Composio (ownership verified inside via artistId entity) await disconnectArtistConnector(artist_id, connected_account_id); return NextResponse.json({ success: true }, { status: 200, headers }); diff --git a/lib/composio/artistConnectors/authorizeArtistConnector.ts b/lib/composio/artistConnectors/authorizeArtistConnector.ts index 8b95d409..2a04d548 100644 --- a/lib/composio/artistConnectors/authorizeArtistConnector.ts +++ b/lib/composio/artistConnectors/authorizeArtistConnector.ts @@ -12,16 +12,14 @@ export interface AuthorizeArtistConnectorResult { /** * Generate an OAuth authorization URL for an artist connector. * - * Why: Used by the /api/artist-connectors/authorize endpoint to let users - * connect external services (like TikTok) for a specific artist. + * Uses artistId as the Composio entity so that connections are stored + * under the artist, not the user. This keeps Composio as the source of truth. * - * @param accountId - The user's account ID (used as Composio entity) - * @param artistId - The artist ID to associate the connection with + * @param artistId - The artist ID (used as Composio entity) * @param connector - The connector slug (e.g., "tiktok") * @returns The redirect URL for OAuth */ export async function authorizeArtistConnector( - accountId: string, artistId: string, connector: string, ): Promise { @@ -33,7 +31,8 @@ export async function authorizeArtistConnector( toolkit: connector, }); - const session = await composio.create(accountId, { + // Use artistId as the Composio entity - connection will be stored under the artist + const session = await composio.create(artistId, { manageConnections: { callbackUrl, }, diff --git a/lib/composio/artistConnectors/completeArtistConnector.ts b/lib/composio/artistConnectors/completeArtistConnector.ts deleted file mode 100644 index 02166a8b..00000000 --- a/lib/composio/artistConnectors/completeArtistConnector.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getComposioClient } from "../client"; -import { insertArtistComposioConnection } from "@/lib/supabase/artist_composio_connections/insertArtistComposioConnection"; - -/** - * Result of completing an artist connector OAuth flow. - */ -export interface CompleteArtistConnectorResult { - success: boolean; - connectedAccountId: string; -} - -/** - * Complete the OAuth flow for an artist connector. - * - * Why: After OAuth redirect, we need to query Composio for the newly - * created connection and store the mapping in our database. - * - * @param accountId - The user's account ID (Composio entity) - * @param artistId - The artist ID to associate the connection with - * @param toolkitSlug - The toolkit slug (e.g., "tiktok") - * @returns The result with the connected account ID - * @throws Error if connection not found or database insert fails - */ -export async function completeArtistConnector( - accountId: string, - artistId: string, - toolkitSlug: string, -): Promise { - const composio = await getComposioClient(); - - // Create a session for the user to query their connections - const session = await composio.create(accountId); - - // Get all toolkits to find the connected one - const toolkits = await session.toolkits(); - - // Find the toolkit matching the slug - const toolkit = toolkits.items.find((t) => t.slug === toolkitSlug); - - if (!toolkit || !toolkit.connection?.isActive || !toolkit.connection?.connectedAccount?.id) { - throw new Error(`No active connection found for toolkit '${toolkitSlug}'`); - } - - const connectedAccountId = toolkit.connection.connectedAccount.id; - - // Store the mapping in our database - const { error } = await insertArtistComposioConnection({ - artist_id: artistId, - toolkit_slug: toolkitSlug, - connected_account_id: connectedAccountId, - }); - - if (error) { - throw new Error("Failed to save connection to database"); - } - - return { - success: true, - connectedAccountId, - }; -} diff --git a/lib/composio/artistConnectors/disconnectArtistConnector.ts b/lib/composio/artistConnectors/disconnectArtistConnector.ts index 53746b4a..15cd623f 100644 --- a/lib/composio/artistConnectors/disconnectArtistConnector.ts +++ b/lib/composio/artistConnectors/disconnectArtistConnector.ts @@ -1,28 +1,33 @@ import { getComposioApiKey } from "../getComposioApiKey"; -import { deleteArtistComposioConnection } from "@/lib/supabase/artist_composio_connections/deleteArtistComposioConnection"; -import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; +import { getComposioClient } from "../client"; /** - * Disconnect an artist connector from Composio and remove the DB record. + * Disconnect an artist connector from Composio. * - * Why: When an artist disconnects a service (like TikTok), we need to: - * 1. Delete the connected account from Composio's side - * 2. Remove the mapping from our artist_composio_connections table + * Uses artistId as the Composio entity to verify the connection exists + * and belongs to this artist before deleting. * - * @param artistId - The artist ID + * @param artistId - The artist ID (Composio entity) * @param connectedAccountId - The ID of the connected account to disconnect * @returns Success status */ export async function disconnectArtistConnector( artistId: string, - connectedAccountId: string, + connectedAccountId: string ): Promise<{ success: boolean }> { - // First, find the connection record in our DB to get the ID - const connections = await selectArtistComposioConnections(artistId); - const connection = connections.find(c => c.connected_account_id === connectedAccountId); + const composio = await getComposioClient(); - if (!connection) { - throw new Error("Connection not found"); + // Create session with artistId to verify the connection belongs to this artist + const session = await composio.create(artistId); + const toolkits = await session.toolkits(); + + // Find the connection to verify ownership + const hasConnection = toolkits.items.some( + (toolkit) => toolkit.connection?.connectedAccount?.id === connectedAccountId + ); + + if (!hasConnection) { + throw new Error("Connection not found for this artist"); } // Delete from Composio using their v3 API @@ -39,14 +44,9 @@ export async function disconnectArtistConnector( if (!response.ok) { const errorText = await response.text(); - throw new Error(`Failed to disconnect from Composio (${response.status}): ${errorText}`); - } - - // Remove from our DB - const { error } = await deleteArtistComposioConnection(connection.id); - - if (error) { - throw new Error(`Failed to remove connection record: ${error.message}`); + throw new Error( + `Failed to disconnect from Composio (${response.status}): ${errorText}` + ); } return { success: true }; diff --git a/lib/composio/artistConnectors/getArtistConnectors.ts b/lib/composio/artistConnectors/getArtistConnectors.ts index 172f5694..b41f1a59 100644 --- a/lib/composio/artistConnectors/getArtistConnectors.ts +++ b/lib/composio/artistConnectors/getArtistConnectors.ts @@ -1,4 +1,4 @@ -import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; +import { getComposioClient } from "../client"; import { ALLOWED_ARTIST_CONNECTORS } from "./ALLOWED_ARTIST_CONNECTORS"; /** @@ -21,24 +21,36 @@ const CONNECTOR_NAMES: Record = { /** * Get all allowed artist connectors with their connection status. * - * Returns the list of ALLOWED_ARTIST_CONNECTORS with isConnected status - * based on existing connections in artist_composio_connections table. + * Queries Composio directly using artistId as the entity to check + * which connectors are connected. Composio is the source of truth. * - * @param artistId - The artist ID to get connectors for + * @param artistId - The artist ID (used as Composio entity) * @returns Array of connector info with connection status */ -export async function getArtistConnectors(artistId: string): Promise { - // Fetch existing connections for this artist - const existingConnections = await selectArtistComposioConnections(artistId); +export async function getArtistConnectors( + artistId: string +): Promise { + const composio = await getComposioClient(); - // Create a map of toolkit_slug -> connected_account_id for quick lookup + // Create session with artistId as entity to check their connections + const session = await composio.create(artistId, { + toolkits: ALLOWED_ARTIST_CONNECTORS, + }); + + // Get all toolkits and their connection status + const toolkits = await session.toolkits(); + + // Create a map of slug -> connectedAccountId for quick lookup const connectionMap = new Map(); - for (const conn of existingConnections) { - connectionMap.set(conn.toolkit_slug, conn.connected_account_id); + for (const toolkit of toolkits.items) { + const connectedAccountId = toolkit.connection?.connectedAccount?.id; + if (connectedAccountId) { + connectionMap.set(toolkit.slug, connectedAccountId); + } } - // Build connector list with status - return ALLOWED_ARTIST_CONNECTORS.map(slug => { + // Build connector list with status for allowed connectors + return ALLOWED_ARTIST_CONNECTORS.map((slug) => { const connectedAccountId = connectionMap.get(slug); return { slug, diff --git a/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts b/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts deleted file mode 100644 index ef7d1158..00000000 --- a/lib/composio/artistConnectors/validateCompleteArtistConnectorBody.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; - -/** - * Schema for the complete artist connector request body. - */ -export const completeArtistConnectorBodySchema = z.object({ - artist_id: z - .string({ message: "artist_id is required" }) - .uuid("artist_id must be a valid UUID"), - toolkit_slug: z - .string({ message: "toolkit_slug is required" }) - .min(1, "toolkit_slug cannot be empty (e.g., 'tiktok')"), -}); - -export type CompleteArtistConnectorBody = z.infer; - -/** - * Validate the complete artist connector request body. - * - * @param body - The request body to validate - * @returns The validated body or a NextResponse with error - */ -export function validateCompleteArtistConnectorBody( - body: unknown, -): CompleteArtistConnectorBody | NextResponse { - const result = completeArtistConnectorBodySchema.safeParse(body); - - if (!result.success) { - const message = result.error.issues.map((i) => i.message).join(", "); - return NextResponse.json( - { error: message }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - return result.data; -} diff --git a/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts b/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts deleted file mode 100644 index 99b02b61..00000000 --- a/lib/composio/artistConnectors/verifyArtistConnectorOwnership.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; - -/** - * Verifies that a connected account ID belongs to the specified artist. - * - * Why: Before disconnecting an artist connector, we must verify that the - * connected account actually belongs to this artist to prevent users from - * disconnecting connections from other artists they may not own. - * - * @param artistId - The artist ID - * @param connectedAccountId - The connected account ID to verify - * @returns true if the connected account belongs to the artist, false otherwise - */ -export async function verifyArtistConnectorOwnership( - artistId: string, - connectedAccountId: string, -): Promise { - const connections = await selectArtistComposioConnections(artistId); - - // Check if any of the artist's connections match this connected account ID - return connections.some(connection => connection.connected_account_id === connectedAccountId); -} diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 343473ca..46e8e40b 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,6 +1,7 @@ import { createToolRouterSession } from "./createSession"; +import { getComposioClient } from "../client"; +import { ALLOWED_ARTIST_CONNECTORS } from "../artistConnectors/ALLOWED_ARTIST_CONNECTORS"; import type { Tool, ToolSet } from "ai"; -import { selectArtistComposioConnections } from "@/lib/supabase/artist_composio_connections/selectArtistComposioConnections"; /** * Tools we want to expose from Composio Tool Router. @@ -36,6 +37,39 @@ function isValidTool(tool: unknown): tool is Tool { return hasExecute && hasSchema; } +/** + * Query Composio for an artist's connected accounts. + * + * Uses artistId as the Composio entity to get their connections. + * Only returns connections for ALLOWED_ARTIST_CONNECTORS (e.g., tiktok). + * + * @param artistId - The artist ID (Composio entity) + * @returns Map of toolkit slug to connected account ID + */ +async function getArtistConnectionsFromComposio( + artistId: string +): Promise> { + const composio = await getComposioClient(); + + // Create session with artistId as entity + const session = await composio.create(artistId, { + toolkits: ALLOWED_ARTIST_CONNECTORS, + }); + + // Get toolkits and extract connected account IDs + const toolkits = await session.toolkits(); + const connections: Record = {}; + + for (const toolkit of toolkits.items) { + const connectedAccountId = toolkit.connection?.connectedAccount?.id; + if (connectedAccountId && ALLOWED_ARTIST_CONNECTORS.includes(toolkit.slug)) { + connections[toolkit.slug] = connectedAccountId; + } + } + + return connections; +} + /** * Get Composio Tool Router tools for a user. * @@ -45,6 +79,9 @@ function isValidTool(tool: unknown): tool is Tool { * - COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas * - COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions * + * If artistId is provided, queries Composio for the artist's connections + * and passes them to the session via connectedAccounts override. + * * Gracefully returns empty ToolSet when: * - COMPOSIO_API_KEY is not set * - @composio packages fail to load (bundler incompatibility) @@ -57,7 +94,7 @@ function isValidTool(tool: unknown): tool is Tool { export async function getComposioTools( userId: string, artistId?: string, - roomId?: string, + roomId?: string ): Promise { // Skip Composio if API key is not configured if (!process.env.COMPOSIO_API_KEY) { @@ -65,19 +102,13 @@ export async function getComposioTools( } try { - // Fetch artist-specific Composio connections if artistId is provided + // Fetch artist-specific connections from Composio if artistId is provided let artistConnections: Record | undefined; if (artistId) { - const connections = await selectArtistComposioConnections(artistId); - if (connections.length > 0) { - // Transform to { toolkit_slug: connected_account_id } format - artistConnections = connections.reduce( - (acc, conn) => { - acc[conn.toolkit_slug] = conn.connected_account_id; - return acc; - }, - {} as Record, - ); + artistConnections = await getArtistConnectionsFromComposio(artistId); + // Only pass if there are actual connections + if (Object.keys(artistConnections).length === 0) { + artistConnections = undefined; } } diff --git a/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts deleted file mode 100644 index 3ed705ac..00000000 --- a/lib/supabase/artist_composio_connections/deleteArtistComposioConnection.ts +++ /dev/null @@ -1,20 +0,0 @@ -import supabase from "../serverClient"; - -/** - * Deletes an artist_composio_connection by its ID. - * - * @param connectionId - The connection's unique ID - * @returns Object with error property (null if successful) - */ -export async function deleteArtistComposioConnection(connectionId: string) { - const { error } = await supabase - .from("artist_composio_connections") - .delete() - .eq("id", connectionId); - - if (error) { - return { error }; - } - - return { error: null }; -} diff --git a/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts deleted file mode 100644 index cfeffaea..00000000 --- a/lib/supabase/artist_composio_connections/insertArtistComposioConnection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import supabase from "../serverClient"; -import type { Tables, TablesInsert } from "@/types/database.types"; - -/** - * Inserts or updates (upserts) an artist_composio_connection. - * Uses the unique constraint on (artist_id, toolkit_slug) for conflict resolution. - * - * @param connection - The connection data to insert - * @returns The upserted connection row - */ -export async function insertArtistComposioConnection( - connection: TablesInsert<"artist_composio_connections">, -): Promise<{ data: Tables<"artist_composio_connections"> | null; error: unknown }> { - const { data, error } = await supabase - .from("artist_composio_connections") - .upsert(connection, { - onConflict: "artist_id,toolkit_slug", - }) - .select() - .single(); - - if (error) { - return { data: null, error }; - } - - return { data, error: null }; -} diff --git a/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts b/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts deleted file mode 100644 index 39173ed1..00000000 --- a/lib/supabase/artist_composio_connections/selectArtistComposioConnection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import supabase from "../serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects a single artist_composio_connection by artist_id and toolkit_slug. - * - * @param artistId - The artist's ID (from account_info) - * @param toolkitSlug - The toolkit identifier (e.g., 'tiktok') - * @returns The connection row or null if not found - */ -export async function selectArtistComposioConnection( - artistId: string, - toolkitSlug: string, -): Promise | null> { - const { data, error } = await supabase - .from("artist_composio_connections") - .select("*") - .eq("artist_id", artistId) - .eq("toolkit_slug", toolkitSlug) - .single(); - - if (error || !data) { - return null; - } - - return data; -} diff --git a/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts b/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts deleted file mode 100644 index bbff6354..00000000 --- a/lib/supabase/artist_composio_connections/selectArtistComposioConnections.ts +++ /dev/null @@ -1,23 +0,0 @@ -import supabase from "../serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects all artist_composio_connections for a given artist. - * - * @param artistId - The artist's ID (from account_info) - * @returns Array of connection rows, or empty array if none found - */ -export async function selectArtistComposioConnections( - artistId: string, -): Promise[]> { - const { data, error } = await supabase - .from("artist_composio_connections") - .select("*") - .eq("artist_id", artistId); - - if (error || !data) { - return []; - } - - return data; -} diff --git a/types/database.types.ts b/types/database.types.ts index d4cfa237..4aee9ca8 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -797,41 +797,6 @@ export type Database = { }, ]; }; - artist_composio_connections: { - Row: { - id: string; - artist_id: string; - toolkit_slug: string; - connected_account_id: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - artist_id: string; - toolkit_slug: string; - connected_account_id: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - artist_id?: string; - toolkit_slug?: string; - connected_account_id?: string; - created_at?: string; - updated_at?: string; - }; - Relationships: [ - { - foreignKeyName: "artist_composio_connections_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "account_info"; - referencedColumns: ["id"]; - }, - ]; - }; artist_fan_segment: { Row: { artist_social_id: string | null; From 933a702b8b34c655d8c4d3b32163aac57bf20d8a Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:54:59 -0500 Subject: [PATCH 11/42] fix: resolve TypeScript errors with readonly array types --- lib/composio/artistConnectors/getArtistConnectors.ts | 3 ++- lib/composio/toolRouter/getTools.ts | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/composio/artistConnectors/getArtistConnectors.ts b/lib/composio/artistConnectors/getArtistConnectors.ts index b41f1a59..2c7bdea5 100644 --- a/lib/composio/artistConnectors/getArtistConnectors.ts +++ b/lib/composio/artistConnectors/getArtistConnectors.ts @@ -33,8 +33,9 @@ export async function getArtistConnectors( const composio = await getComposioClient(); // Create session with artistId as entity to check their connections + // Spread to create mutable array (ALLOWED_ARTIST_CONNECTORS is readonly) const session = await composio.create(artistId, { - toolkits: ALLOWED_ARTIST_CONNECTORS, + toolkits: [...ALLOWED_ARTIST_CONNECTORS], }); // Get all toolkits and their connection status diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 46e8e40b..54844e27 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -52,8 +52,9 @@ async function getArtistConnectionsFromComposio( const composio = await getComposioClient(); // Create session with artistId as entity + // Spread to create mutable array (ALLOWED_ARTIST_CONNECTORS is readonly) const session = await composio.create(artistId, { - toolkits: ALLOWED_ARTIST_CONNECTORS, + toolkits: [...ALLOWED_ARTIST_CONNECTORS], }); // Get toolkits and extract connected account IDs @@ -62,7 +63,9 @@ async function getArtistConnectionsFromComposio( for (const toolkit of toolkits.items) { const connectedAccountId = toolkit.connection?.connectedAccount?.id; - if (connectedAccountId && ALLOWED_ARTIST_CONNECTORS.includes(toolkit.slug)) { + // Cast to readonly string[] for .includes() type compatibility + const allowedConnectors = ALLOWED_ARTIST_CONNECTORS as readonly string[]; + if (connectedAccountId && allowedConnectors.includes(toolkit.slug)) { connections[toolkit.slug] = connectedAccountId; } } From 23248d492ec3a6046bb547572be761a2f64eff14 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:26:07 -0500 Subject: [PATCH 12/42] refactor: DRY up composio connectors - unified authorizeConnector, getConnectors, disconnectConnector --- app/api/artist-connectors/authorize/route.ts | 20 ++++-- app/api/artist-connectors/route.ts | 29 +++++++-- app/api/connectors/authorize/route.ts | 4 +- .../authorizeArtistConnector.ts | 47 -------------- .../disconnectArtistConnector.ts | 53 --------------- .../artistConnectors/getArtistConnectors.ts | 63 ------------------ .../allowedArtistConnectors.ts} | 0 lib/composio/connectors/authorizeConnector.ts | 59 ++++++++++++++--- .../connectors/disconnectConnector.ts | 27 ++++++++ lib/composio/connectors/getConnectors.ts | 64 +++++++++++++++++-- lib/composio/connectors/index.ts | 23 ++++++- lib/composio/toolRouter/getTools.ts | 25 +++----- 12 files changed, 203 insertions(+), 211 deletions(-) delete mode 100644 lib/composio/artistConnectors/authorizeArtistConnector.ts delete mode 100644 lib/composio/artistConnectors/disconnectArtistConnector.ts delete mode 100644 lib/composio/artistConnectors/getArtistConnectors.ts rename lib/composio/{artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts => connectors/allowedArtistConnectors.ts} (100%) diff --git a/app/api/artist-connectors/authorize/route.ts b/app/api/artist-connectors/authorize/route.ts index 1343c204..49e6f2df 100644 --- a/app/api/artist-connectors/authorize/route.ts +++ b/app/api/artist-connectors/authorize/route.ts @@ -4,8 +4,10 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { validateAuthorizeArtistConnectorBody } from "@/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody"; -import { authorizeArtistConnector } from "@/lib/composio/artistConnectors/authorizeArtistConnector"; -import { isAllowedArtistConnector } from "@/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS"; +import { + authorizeConnector, + isAllowedArtistConnector, +} from "@/lib/composio/connectors"; /** * OPTIONS handler for CORS preflight requests. @@ -71,8 +73,17 @@ export async function POST(request: NextRequest): Promise { ); } - // Generate OAuth URL - uses artistId as Composio entity - const result = await authorizeArtistConnector(artist_id, connector); + // Build auth configs for toolkits that need custom OAuth + const authConfigs: Record = {}; + if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { + authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + } + + // Generate OAuth URL using unified authorizeConnector with artist options + const result = await authorizeConnector(artist_id, connector, { + entityType: "artist", + authConfigs, + }); return NextResponse.json( { @@ -85,6 +96,7 @@ export async function POST(request: NextRequest): Promise { { status: 200, headers }, ); } catch (error) { + console.error("Artist connector authorize error:", error); const message = error instanceof Error ? error.message : "Failed to authorize artist connector"; return NextResponse.json({ error: message }, { status: 500, headers }); diff --git a/app/api/artist-connectors/route.ts b/app/api/artist-connectors/route.ts index af74eabb..5f96137e 100644 --- a/app/api/artist-connectors/route.ts +++ b/app/api/artist-connectors/route.ts @@ -3,9 +3,19 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -import { getArtistConnectors } from "@/lib/composio/artistConnectors/getArtistConnectors"; import { validateDisconnectArtistConnectorBody } from "@/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody"; -import { disconnectArtistConnector } from "@/lib/composio/artistConnectors/disconnectArtistConnector"; +import { + getConnectors, + disconnectConnector, + ALLOWED_ARTIST_CONNECTORS, +} from "@/lib/composio/connectors"; + +/** + * Display names for artist connectors. + */ +const CONNECTOR_DISPLAY_NAMES: Record = { + tiktok: "TikTok", +}; /** * OPTIONS handler for CORS preflight requests. @@ -61,8 +71,11 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); } - // Get connectors with status - const connectors = await getArtistConnectors(artistId); + // Get connectors with status using unified function + const connectors = await getConnectors(artistId, { + allowedToolkits: ALLOWED_ARTIST_CONNECTORS, + displayNames: CONNECTOR_DISPLAY_NAMES, + }); return NextResponse.json( { @@ -82,7 +95,7 @@ export async function GET(request: NextRequest): Promise { /** * DELETE /api/artist-connectors * - * Disconnect an artist's connector from Composio and remove the connection record. + * Disconnect an artist's connector from Composio. * * Body: * - artist_id (required): The artist ID @@ -123,8 +136,10 @@ export async function DELETE(request: NextRequest): Promise { ); } - // Disconnect from Composio (ownership verified inside via artistId entity) - await disconnectArtistConnector(artist_id, connected_account_id); + // Disconnect using unified function with ownership verification + await disconnectConnector(connected_account_id, { + verifyOwnershipFor: artist_id, + }); return NextResponse.json({ success: true }, { status: 200, headers }); } catch (error) { diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index 1d539a12..d76e0ec4 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -47,7 +47,9 @@ export async function POST(request: NextRequest): Promise { } const { connector, callback_url } = validated; - const result = await authorizeConnector(accountId, connector, callback_url); + const result = await authorizeConnector(accountId, connector, { + customCallbackUrl: callback_url, + }); return NextResponse.json( { diff --git a/lib/composio/artistConnectors/authorizeArtistConnector.ts b/lib/composio/artistConnectors/authorizeArtistConnector.ts deleted file mode 100644 index 2a04d548..00000000 --- a/lib/composio/artistConnectors/authorizeArtistConnector.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getComposioClient } from "../client"; -import { getCallbackUrl } from "../getCallbackUrl"; - -/** - * Result of authorizing an artist connector. - */ -export interface AuthorizeArtistConnectorResult { - connector: string; - redirectUrl: string; -} - -/** - * Generate an OAuth authorization URL for an artist connector. - * - * Uses artistId as the Composio entity so that connections are stored - * under the artist, not the user. This keeps Composio as the source of truth. - * - * @param artistId - The artist ID (used as Composio entity) - * @param connector - The connector slug (e.g., "tiktok") - * @returns The redirect URL for OAuth - */ -export async function authorizeArtistConnector( - artistId: string, - connector: string, -): Promise { - const composio = await getComposioClient(); - - const callbackUrl = getCallbackUrl({ - destination: "artist-connectors", - artistId, - toolkit: connector, - }); - - // Use artistId as the Composio entity - connection will be stored under the artist - const session = await composio.create(artistId, { - manageConnections: { - callbackUrl, - }, - }); - - const connectionRequest = await session.authorize(connector); - - return { - connector, - redirectUrl: connectionRequest.redirectUrl, - }; -} diff --git a/lib/composio/artistConnectors/disconnectArtistConnector.ts b/lib/composio/artistConnectors/disconnectArtistConnector.ts deleted file mode 100644 index 15cd623f..00000000 --- a/lib/composio/artistConnectors/disconnectArtistConnector.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getComposioApiKey } from "../getComposioApiKey"; -import { getComposioClient } from "../client"; - -/** - * Disconnect an artist connector from Composio. - * - * Uses artistId as the Composio entity to verify the connection exists - * and belongs to this artist before deleting. - * - * @param artistId - The artist ID (Composio entity) - * @param connectedAccountId - The ID of the connected account to disconnect - * @returns Success status - */ -export async function disconnectArtistConnector( - artistId: string, - connectedAccountId: string -): Promise<{ success: boolean }> { - const composio = await getComposioClient(); - - // Create session with artistId to verify the connection belongs to this artist - const session = await composio.create(artistId); - const toolkits = await session.toolkits(); - - // Find the connection to verify ownership - const hasConnection = toolkits.items.some( - (toolkit) => toolkit.connection?.connectedAccount?.id === connectedAccountId - ); - - if (!hasConnection) { - throw new Error("Connection not found for this artist"); - } - - // Delete from Composio using their v3 API - const apiKey = getComposioApiKey(); - const url = `https://backend.composio.dev/api/v3/connected_accounts/${connectedAccountId}`; - - const response = await fetch(url, { - method: "DELETE", - headers: { - "x-api-key": apiKey, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to disconnect from Composio (${response.status}): ${errorText}` - ); - } - - return { success: true }; -} diff --git a/lib/composio/artistConnectors/getArtistConnectors.ts b/lib/composio/artistConnectors/getArtistConnectors.ts deleted file mode 100644 index 2c7bdea5..00000000 --- a/lib/composio/artistConnectors/getArtistConnectors.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getComposioClient } from "../client"; -import { ALLOWED_ARTIST_CONNECTORS } from "./ALLOWED_ARTIST_CONNECTORS"; - -/** - * Artist connector info with connection status. - */ -export interface ArtistConnectorInfo { - slug: string; - name: string; - isConnected: boolean; - connectedAccountId?: string; -} - -/** - * Human-readable names for allowed artist connectors. - */ -const CONNECTOR_NAMES: Record = { - tiktok: "TikTok", -}; - -/** - * Get all allowed artist connectors with their connection status. - * - * Queries Composio directly using artistId as the entity to check - * which connectors are connected. Composio is the source of truth. - * - * @param artistId - The artist ID (used as Composio entity) - * @returns Array of connector info with connection status - */ -export async function getArtistConnectors( - artistId: string -): Promise { - const composio = await getComposioClient(); - - // Create session with artistId as entity to check their connections - // Spread to create mutable array (ALLOWED_ARTIST_CONNECTORS is readonly) - const session = await composio.create(artistId, { - toolkits: [...ALLOWED_ARTIST_CONNECTORS], - }); - - // Get all toolkits and their connection status - const toolkits = await session.toolkits(); - - // Create a map of slug -> connectedAccountId for quick lookup - const connectionMap = new Map(); - for (const toolkit of toolkits.items) { - const connectedAccountId = toolkit.connection?.connectedAccount?.id; - if (connectedAccountId) { - connectionMap.set(toolkit.slug, connectedAccountId); - } - } - - // Build connector list with status for allowed connectors - return ALLOWED_ARTIST_CONNECTORS.map((slug) => { - const connectedAccountId = connectionMap.get(slug); - return { - slug, - name: CONNECTOR_NAMES[slug] || slug, - isConnected: !!connectedAccountId, - connectedAccountId, - }; - }); -} diff --git a/lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts b/lib/composio/connectors/allowedArtistConnectors.ts similarity index 100% rename from lib/composio/artistConnectors/ALLOWED_ARTIST_CONNECTORS.ts rename to lib/composio/connectors/allowedArtistConnectors.ts diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index 7b5b44a6..23af2632 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -9,28 +9,67 @@ export interface AuthorizeResult { redirectUrl: string; } +/** + * Options for authorizing a connector. + */ +export interface AuthorizeConnectorOptions { + /** + * Entity type determines how the callback URL is built. + * - "user": Redirects to /settings/connectors + * - "artist": Redirects to /chat with artist_connected param + */ + entityType?: "user" | "artist"; + /** + * For artist entities, the toolkit being connected (for callback URL). + */ + toolkit?: string; + /** + * Custom auth configs for toolkits that require user-provided OAuth credentials. + * e.g., { tiktok: "ac_xxxxx" } + */ + authConfigs?: Record; + /** + * Custom callback URL (overrides default based on entityType). + */ + customCallbackUrl?: string; +} + /** * Generate an OAuth authorization URL for a connector. * - * Why: Used by the /api/connectors/authorize endpoint to let users - * connect from the settings page (not in-chat). + * Works for both user-level and artist-level connections. + * The entityId can be either a userId or artistId - Composio treats them the same. * - * @param userId - The user's account ID - * @param connector - The connector slug (e.g., "googlesheets", "gmail") - * @param customCallbackUrl - Optional custom callback URL after OAuth + * @param entityId - The entity ID (userId or artistId) + * @param connector - The connector slug (e.g., "googlesheets", "tiktok") + * @param options - Authorization options * @returns The redirect URL for OAuth */ export async function authorizeConnector( - userId: string, + entityId: string, connector: string, - customCallbackUrl?: string, + options: AuthorizeConnectorOptions = {}, ): Promise { + const { entityType = "user", toolkit, authConfigs, customCallbackUrl } = options; const composio = await getComposioClient(); - const callbackUrl = - customCallbackUrl || getCallbackUrl({ destination: "connectors" }); + // Build callback URL based on entity type + let callbackUrl: string; + if (customCallbackUrl) { + callbackUrl = customCallbackUrl; + } else if (entityType === "artist") { + callbackUrl = getCallbackUrl({ + destination: "artist-connectors", + artistId: entityId, + toolkit: toolkit || connector, + }); + } else { + callbackUrl = getCallbackUrl({ destination: "connectors" }); + } - const session = await composio.create(userId, { + // Create session with optional auth configs + const session = await composio.create(entityId, { + ...(authConfigs && Object.keys(authConfigs).length > 0 && { authConfigs }), manageConnections: { callbackUrl, }, diff --git a/lib/composio/connectors/disconnectConnector.ts b/lib/composio/connectors/disconnectConnector.ts index 3aff9b9e..f210f6db 100644 --- a/lib/composio/connectors/disconnectConnector.ts +++ b/lib/composio/connectors/disconnectConnector.ts @@ -1,4 +1,16 @@ import { getComposioApiKey } from "../getComposioApiKey"; +import { getConnectors } from "./getConnectors"; + +/** + * Options for disconnecting a connector. + */ +export interface DisconnectConnectorOptions { + /** + * Entity ID to verify ownership before disconnecting. + * If provided, checks that the connected account belongs to this entity. + */ + verifyOwnershipFor?: string; +} /** * Disconnect a connected account from Composio. @@ -7,11 +19,26 @@ import { getComposioApiKey } from "../getComposioApiKey"; * so we call the REST API directly to delete the connection. * * @param connectedAccountId - The ID of the connected account to disconnect + * @param options - Options for ownership verification * @returns Success status */ export async function disconnectConnector( connectedAccountId: string, + options: DisconnectConnectorOptions = {}, ): Promise<{ success: boolean }> { + const { verifyOwnershipFor } = options; + + // If ownership verification is requested, check before deleting + if (verifyOwnershipFor) { + const connectors = await getConnectors(verifyOwnershipFor); + const hasConnection = connectors.some( + (c) => c.connectedAccountId === connectedAccountId, + ); + if (!hasConnection) { + throw new Error("Connection not found for this entity"); + } + } + const apiKey = getComposioApiKey(); // Composio v3 API uses DELETE method diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index c8248941..c32178ec 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -11,20 +11,72 @@ export interface ConnectorInfo { } /** - * Get all connectors and their connection status for a user. + * Options for getting connectors. + */ +export interface GetConnectorsOptions { + /** + * Filter to only these toolkit slugs. + * If not provided, returns all toolkits. + */ + allowedToolkits?: readonly string[]; + /** + * Custom display names for toolkits. + * e.g., { tiktok: "TikTok" } + */ + displayNames?: Record; +} + +/** + * Get connectors and their connection status for an entity. + * + * Works for both user-level and artist-level connections. + * The entityId can be either a userId or artistId - Composio treats them the same. * - * @param userId - The user's account ID + * @param entityId - The entity ID (userId or artistId) + * @param options - Options for filtering and display * @returns List of connectors with connection status */ -export async function getConnectors(userId: string): Promise { +export async function getConnectors( + entityId: string, + options: GetConnectorsOptions = {}, +): Promise { + const { allowedToolkits, displayNames = {} } = options; const composio = await getComposioClient(); - const session = await composio.create(userId); + + // Create session, optionally filtering to allowed toolkits + const sessionOptions = allowedToolkits + ? { toolkits: [...allowedToolkits] as string[] } + : undefined; + + const session = await composio.create(entityId, sessionOptions); const toolkits = await session.toolkits(); - return toolkits.items.map((toolkit) => ({ + // Build connector list + const connectors = toolkits.items.map((toolkit) => ({ slug: toolkit.slug, - name: toolkit.name, + name: displayNames[toolkit.slug] || toolkit.name, isConnected: toolkit.connection?.isActive ?? false, connectedAccountId: toolkit.connection?.connectedAccount?.id, })); + + // If filtering, ensure we return all allowed toolkits (even if not in Composio response) + if (allowedToolkits) { + const existingSlugs = new Set(connectors.map((c) => c.slug)); + for (const slug of allowedToolkits) { + if (!existingSlugs.has(slug)) { + connectors.push({ + slug, + name: displayNames[slug] || slug, + isConnected: false, + connectedAccountId: undefined, + }); + } + } + // Filter to only allowed and maintain order + return allowedToolkits.map( + (slug) => connectors.find((c) => c.slug === slug)!, + ); + } + + return connectors; } diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts index fa866c66..4f3fcb76 100644 --- a/lib/composio/connectors/index.ts +++ b/lib/composio/connectors/index.ts @@ -1,3 +1,20 @@ -export { getConnectors, type ConnectorInfo } from "./getConnectors"; -export { authorizeConnector, type AuthorizeResult } from "./authorizeConnector"; -export { disconnectConnector } from "./disconnectConnector"; +export { + getConnectors, + type ConnectorInfo, + type GetConnectorsOptions, +} from "./getConnectors"; +export { + authorizeConnector, + type AuthorizeResult, + type AuthorizeConnectorOptions, +} from "./authorizeConnector"; +export { + disconnectConnector, + type DisconnectConnectorOptions, +} from "./disconnectConnector"; +export { + ALLOWED_ARTIST_CONNECTORS, + isAllowedArtistConnector, + type AllowedArtistConnector, +} from "./allowedArtistConnectors"; +export { verifyConnectorOwnership } from "./verifyConnectorOwnership"; diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 54844e27..ebe4091f 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,6 +1,5 @@ import { createToolRouterSession } from "./createSession"; -import { getComposioClient } from "../client"; -import { ALLOWED_ARTIST_CONNECTORS } from "../artistConnectors/ALLOWED_ARTIST_CONNECTORS"; +import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; import type { Tool, ToolSet } from "ai"; /** @@ -49,24 +48,16 @@ function isValidTool(tool: unknown): tool is Tool { async function getArtistConnectionsFromComposio( artistId: string ): Promise> { - const composio = await getComposioClient(); - - // Create session with artistId as entity - // Spread to create mutable array (ALLOWED_ARTIST_CONNECTORS is readonly) - const session = await composio.create(artistId, { - toolkits: [...ALLOWED_ARTIST_CONNECTORS], + // Use unified getConnectors with artist filter + const connectors = await getConnectors(artistId, { + allowedToolkits: ALLOWED_ARTIST_CONNECTORS, }); - // Get toolkits and extract connected account IDs - const toolkits = await session.toolkits(); + // Build connections map from connected connectors const connections: Record = {}; - - for (const toolkit of toolkits.items) { - const connectedAccountId = toolkit.connection?.connectedAccount?.id; - // Cast to readonly string[] for .includes() type compatibility - const allowedConnectors = ALLOWED_ARTIST_CONNECTORS as readonly string[]; - if (connectedAccountId && allowedConnectors.includes(toolkit.slug)) { - connections[toolkit.slug] = connectedAccountId; + for (const connector of connectors) { + if (connector.connectedAccountId) { + connections[connector.slug] = connector.connectedAccountId; } } From c7e4773f4e10d7e4dd1d8eec9a7d7b61c1f15f6d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:44:01 -0500 Subject: [PATCH 13/42] refactor: unify /api/connectors with entity_type support, delete /api/artist-connectors --- app/api/artist-connectors/authorize/route.ts | 104 ------------ app/api/artist-connectors/route.ts | 149 ------------------ app/api/connectors/authorize/route.ts | 47 +++++- app/api/connectors/route.ts | 101 +++++++++--- .../validateAuthorizeArtistConnectorBody.ts | 43 ----- .../validateDisconnectArtistConnectorBody.ts | 37 ----- .../validateAuthorizeConnectorBody.ts | 40 +++-- .../validateDisconnectConnectorBody.ts | 32 +++- 8 files changed, 177 insertions(+), 376 deletions(-) delete mode 100644 app/api/artist-connectors/authorize/route.ts delete mode 100644 app/api/artist-connectors/route.ts delete mode 100644 lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts delete mode 100644 lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts diff --git a/app/api/artist-connectors/authorize/route.ts b/app/api/artist-connectors/authorize/route.ts deleted file mode 100644 index 49e6f2df..00000000 --- a/app/api/artist-connectors/authorize/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -import { validateAuthorizeArtistConnectorBody } from "@/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody"; -import { - authorizeConnector, - isAllowedArtistConnector, -} from "@/lib/composio/connectors"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns Empty response with CORS headers - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/artist-connectors/authorize - * - * Generate an OAuth authorization URL for an artist connector. - * - * Authentication: x-api-key OR Authorization Bearer token required. - * - * Request body: - * - artist_id: The artist ID to connect the service for (required) - * - connector: The connector slug, e.g., "tiktok" (required) - * - * @param request - The incoming request - * @returns The redirect URL for OAuth authorization - */ -export async function POST(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - // Validate auth - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - // Parse and validate body - const body = await request.json(); - const validated = validateAuthorizeArtistConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { artist_id, connector } = validated; - - // Verify connector is allowed - if (!isAllowedArtistConnector(connector)) { - return NextResponse.json( - { error: `Connector '${connector}' is not allowed for artist connections` }, - { status: 400, headers }, - ); - } - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, artist_id); - if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); - } - - // Build auth configs for toolkits that need custom OAuth - const authConfigs: Record = {}; - if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { - authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; - } - - // Generate OAuth URL using unified authorizeConnector with artist options - const result = await authorizeConnector(artist_id, connector, { - entityType: "artist", - authConfigs, - }); - - return NextResponse.json( - { - success: true, - data: { - connector: result.connector, - redirectUrl: result.redirectUrl, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - console.error("Artist connector authorize error:", error); - const message = - error instanceof Error ? error.message : "Failed to authorize artist connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } -} diff --git a/app/api/artist-connectors/route.ts b/app/api/artist-connectors/route.ts deleted file mode 100644 index 5f96137e..00000000 --- a/app/api/artist-connectors/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -import { validateDisconnectArtistConnectorBody } from "@/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody"; -import { - getConnectors, - disconnectConnector, - ALLOWED_ARTIST_CONNECTORS, -} from "@/lib/composio/connectors"; - -/** - * Display names for artist connectors. - */ -const CONNECTOR_DISPLAY_NAMES: Record = { - tiktok: "TikTok", -}; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns Empty response with CORS headers - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * GET /api/artist-connectors - * - * List all available connectors and their connection status for an artist. - * - * Query params: - * - artist_id (required): The artist ID to get connectors for - * - * Authentication: x-api-key OR Authorization Bearer token required. - * - * @param request - The incoming request - * @returns List of connectors with connection status - */ -export async function GET(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - // Validate auth - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - // Get artist_id from query params - const { searchParams } = new URL(request.url); - const artistId = searchParams.get("artist_id"); - - if (!artistId) { - return NextResponse.json( - { error: "artist_id query parameter is required" }, - { status: 400, headers }, - ); - } - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, artistId); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); - } - - // Get connectors with status using unified function - const connectors = await getConnectors(artistId, { - allowedToolkits: ALLOWED_ARTIST_CONNECTORS, - displayNames: CONNECTOR_DISPLAY_NAMES, - }); - - return NextResponse.json( - { - success: true, - data: { - connectors, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to fetch artist connectors"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } -} - -/** - * DELETE /api/artist-connectors - * - * Disconnect an artist's connector from Composio. - * - * Body: - * - artist_id (required): The artist ID - * - connected_account_id (required): The connected account ID to disconnect - * - * Authentication: x-api-key OR Authorization Bearer token required. - * - * @param request - The incoming request - * @returns Success status - */ -export async function DELETE(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - // Validate auth - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - // Parse and validate body - const body = await request.json(); - const validated = validateDisconnectArtistConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { artist_id, connected_account_id } = validated; - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, artist_id); - if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); - } - - // Disconnect using unified function with ownership verification - await disconnectConnector(connected_account_id, { - verifyOwnershipFor: artist_id, - }); - - return NextResponse.json({ success: true }, { status: 200, headers }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to disconnect artist connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } -} diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index d76e0ec4..39a5f81e 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -1,9 +1,10 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { authorizeConnector } from "@/lib/composio/connectors"; +import { authorizeConnector, isAllowedArtistConnector } from "@/lib/composio/connectors"; import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; import { validateAuthorizeConnectorBody } from "@/lib/composio/connectors/validateAuthorizeConnectorBody"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; /** * OPTIONS handler for CORS preflight requests. @@ -24,8 +25,10 @@ export async function OPTIONS() { * The account ID is inferred from the auth header. * * Request body: - * - connector: The connector slug, e.g., "googlesheets" (required) + * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) * - callback_url: Optional custom callback URL after OAuth + * - entity_type: "user" (default) or "artist" + * - entity_id: Required when entity_type is "artist" * * @returns The redirect URL for OAuth authorization */ @@ -46,9 +49,41 @@ export async function POST(request: NextRequest): Promise { return validated; } - const { connector, callback_url } = validated; - const result = await authorizeConnector(accountId, connector, { + const { connector, callback_url, entity_type, entity_id } = validated; + + // Determine entity and options based on type + let composioEntityId: string; + let authConfigs: Record = {}; + + if (entity_type === "artist") { + // Verify connector is allowed for artists + if (!isAllowedArtistConnector(connector)) { + return NextResponse.json( + { error: `Connector '${connector}' is not allowed for artist connections` }, + { status: 400, headers }, + ); + } + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (!hasAccess) { + return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); + } + + composioEntityId = entity_id!; + + // Build auth configs for toolkits that need custom OAuth + if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { + authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + } + } else { + composioEntityId = accountId; + } + + const result = await authorizeConnector(composioEntityId, connector, { customCallbackUrl: callback_url, + entityType: entity_type, + authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, }); return NextResponse.json( @@ -62,8 +97,8 @@ export async function POST(request: NextRequest): Promise { { status: 200, headers }, ); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to authorize connector"; + console.error("Connector authorize error:", error); + const message = error instanceof Error ? error.message : "Failed to authorize connector"; return NextResponse.json({ error: message }, { status: 500, headers }); } } diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index bdafa4e0..3bfdac2d 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -1,11 +1,21 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getConnectors } from "@/lib/composio/connectors"; -import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector"; +import { getConnectors, disconnectConnector, ALLOWED_ARTIST_CONNECTORS } from "@/lib/composio/connectors"; import { validateDisconnectConnectorBody } from "@/lib/composio/connectors/validateDisconnectConnectorBody"; import { verifyConnectorOwnership } from "@/lib/composio/connectors/verifyConnectorOwnership"; import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; + +/** + * Display names for connectors. + */ +const CONNECTOR_DISPLAY_NAMES: Record = { + tiktok: "TikTok", + googlesheets: "Google Sheets", + googledrive: "Google Drive", + googledocs: "Google Docs", +}; /** * OPTIONS handler for CORS preflight requests. @@ -20,7 +30,11 @@ export async function OPTIONS() { /** * GET /api/connectors * - * List all available connectors and their connection status for a user. + * List all available connectors and their connection status. + * + * Query params: + * - entity_type (optional): "user" (default) or "artist" + * - entity_id (required when entity_type=artist): The artist ID * * Authentication: x-api-key OR Authorization Bearer token required. * @@ -37,7 +51,39 @@ export async function GET(request: NextRequest): Promise { const { accountId } = authResult; - const connectors = await getConnectors(accountId); + // Parse query params + const { searchParams } = new URL(request.url); + const entityType = (searchParams.get("entity_type") || "user") as "user" | "artist"; + const entityId = searchParams.get("entity_id"); + + // Determine the Composio entity to query + let composioEntityId: string; + let allowedToolkits: readonly string[] | undefined; + + if (entityType === "artist") { + if (!entityId) { + return NextResponse.json( + { error: "entity_id is required when entity_type is 'artist'" }, + { status: 400, headers }, + ); + } + + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, entityId); + if (!hasAccess) { + return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); + } + + composioEntityId = entityId; + allowedToolkits = ALLOWED_ARTIST_CONNECTORS; + } else { + composioEntityId = accountId; + } + + const connectors = await getConnectors(composioEntityId, { + allowedToolkits, + displayNames: CONNECTOR_DISPLAY_NAMES, + }); return NextResponse.json( { @@ -49,8 +95,7 @@ export async function GET(request: NextRequest): Promise { { status: 200, headers }, ); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to fetch connectors"; + const message = error instanceof Error ? error.message : "Failed to fetch connectors"; return NextResponse.json({ error: message }, { status: 500, headers }); } } @@ -60,9 +105,12 @@ export async function GET(request: NextRequest): Promise { * * Disconnect a connected account from Composio. * - * Authentication: x-api-key OR Authorization Bearer token required. + * Body: + * - connected_account_id (required): The connected account ID to disconnect + * - entity_type (optional): "user" (default) or "artist" + * - entity_id (required when entity_type=artist): The artist ID * - * Body: { connected_account_id: string } + * Authentication: x-api-key OR Authorization Bearer token required. */ export async function DELETE(request: NextRequest): Promise { const headers = getCorsHeaders(); @@ -81,29 +129,40 @@ export async function DELETE(request: NextRequest): Promise { return validated; } - const { connected_account_id } = validated; + const { connected_account_id, entity_type, entity_id } = validated; - // Verify the connected account belongs to the authenticated user - const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); - if (!isOwner) { - return NextResponse.json( - { error: "Connected account not found or does not belong to this user" }, - { status: 403, headers } - ); - } + if (entity_type === "artist") { + // Verify user has access to this artist + const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (!hasAccess) { + return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); + } + + // Disconnect with ownership verification against artist + await disconnectConnector(connected_account_id, { + verifyOwnershipFor: entity_id!, + }); + } else { + // Verify the connected account belongs to the authenticated user + const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); + if (!isOwner) { + return NextResponse.json( + { error: "Connected account not found or does not belong to this user" }, + { status: 403, headers }, + ); + } - const result = await disconnectConnector(connected_account_id); + await disconnectConnector(connected_account_id); + } return NextResponse.json( { success: true, - data: result, }, { status: 200, headers }, ); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to disconnect connector"; + const message = error instanceof Error ? error.message : "Failed to disconnect connector"; return NextResponse.json({ error: message }, { status: 500, headers }); } } diff --git a/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts b/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts deleted file mode 100644 index fa86ac9d..00000000 --- a/lib/composio/artistConnectors/validateAuthorizeArtistConnectorBody.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const authorizeArtistConnectorBodySchema = z.object({ - artist_id: z - .string({ message: "artist_id is required" }) - .uuid("artist_id must be a valid UUID"), - connector: z - .string({ message: "connector is required" }) - .min(1, "connector cannot be empty (e.g., 'tiktok')"), -}); - -export type AuthorizeArtistConnectorBody = z.infer< - typeof authorizeArtistConnectorBodySchema ->; - -/** - * Validates request body for POST /api/artist-connectors/authorize. - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. - */ -export function validateAuthorizeArtistConnectorBody( - body: unknown, -): NextResponse | AuthorizeArtistConnectorBody { - const result = authorizeArtistConnectorBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} diff --git a/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts b/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts deleted file mode 100644 index 2d3cebf7..00000000 --- a/lib/composio/artistConnectors/validateDisconnectArtistConnectorBody.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const disconnectArtistConnectorBodySchema = z.object({ - artist_id: z.string().uuid("artist_id must be a valid UUID"), - connected_account_id: z.string().min(1, "connected_account_id is required"), -}); - -export type DisconnectArtistConnectorBody = z.infer; - -/** - * Validates request body for DELETE /api/artist-connectors. - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. - */ -export function validateDisconnectArtistConnectorBody( - body: unknown, -): NextResponse | DisconnectArtistConnectorBody { - const result = disconnectArtistConnectorBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index df3570e3..41e60dff 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -2,25 +2,43 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -export const authorizeConnectorBodySchema = z.object({ - connector: z - .string({ message: "connector is required" }) - .min(1, "connector cannot be empty (e.g., 'googlesheets', 'gmail')"), - callback_url: z.string().url("callback_url must be a valid URL").optional(), -}); +export const authorizeConnectorBodySchema = z + .object({ + connector: z + .string({ message: "connector is required" }) + .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), + callback_url: z.string().url("callback_url must be a valid URL").optional(), + entity_type: z.enum(["user", "artist"]).optional().default("user"), + entity_id: z.string().optional(), + }) + .refine( + (data) => { + // entity_id is required when entity_type is "artist" + if (data.entity_type === "artist" && !data.entity_id) { + return false; + } + return true; + }, + { + message: "entity_id is required when entity_type is 'artist'", + path: ["entity_id"], + }, + ); -export type AuthorizeConnectorBody = z.infer< - typeof authorizeConnectorBodySchema ->; +export type AuthorizeConnectorBody = z.infer; /** * Validates request body for POST /api/connectors/authorize. * + * Supports both user and artist connectors: + * - User: { connector: "googlesheets" } + * - Artist: { connector: "tiktok", entity_type: "artist", entity_id: "artist-uuid" } + * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. */ export function validateAuthorizeConnectorBody( - body: unknown + body: unknown, ): NextResponse | AuthorizeConnectorBody { const result = authorizeConnectorBodySchema.safeParse(body); @@ -33,7 +51,7 @@ export function validateAuthorizeConnectorBody( { status: 400, headers: getCorsHeaders(), - } + }, ); } diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index 70dfb150..bd6b1295 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -2,19 +2,41 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -export const disconnectConnectorBodySchema = z.object({ - connected_account_id: z.string().min(1, "connected_account_id is required"), -}); +export const disconnectConnectorBodySchema = z + .object({ + connected_account_id: z.string().min(1, "connected_account_id is required"), + entity_type: z.enum(["user", "artist"]).optional().default("user"), + entity_id: z.string().optional(), + }) + .refine( + (data) => { + // entity_id is required when entity_type is "artist" + if (data.entity_type === "artist" && !data.entity_id) { + return false; + } + return true; + }, + { + message: "entity_id is required when entity_type is 'artist'", + path: ["entity_id"], + }, + ); export type DisconnectConnectorBody = z.infer; /** - * Validates request body for POST /api/connectors/disconnect. + * Validates request body for DELETE /api/connectors. + * + * Supports both user and artist connectors: + * - User: { connected_account_id: "ca_xxx" } + * - Artist: { connected_account_id: "ca_xxx", entity_type: "artist", entity_id: "artist-uuid" } * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. */ -export function validateDisconnectConnectorBody(body: unknown): NextResponse | DisconnectConnectorBody { +export function validateDisconnectConnectorBody( + body: unknown, +): NextResponse | DisconnectConnectorBody { const result = disconnectConnectorBodySchema.safeParse(body); if (!result.success) { From 3b404a45548e9f59c06ef6212ea5f8d0ab591ade Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 1 Feb 2026 10:19:44 -0500 Subject: [PATCH 14/42] refactor: address code review - SRP handlers, unified validators, tests, file rename --- app/api/connectors/authorize/route.ts | 77 +------- app/api/connectors/route.ts | 132 +------------ .../validateAuthorizeConnectorRequest.test.ts | 168 +++++++++++++++++ ...validateDisconnectConnectorRequest.test.ts | 176 ++++++++++++++++++ .../validateGetConnectorsRequest.test.ts | 122 ++++++++++++ .../connectors/authorizeConnectorHandler.ts | 53 ++++++ .../connectors/disconnectConnectorHandler.ts | 45 +++++ .../connectors/getConnectorsHandler.ts | 58 ++++++ lib/composio/connectors/index.ts | 2 +- ...nectors.ts => isAllowedArtistConnector.ts} | 0 .../validateAuthorizeConnectorBody.ts | 19 ++ .../validateAuthorizeConnectorRequest.ts | 81 ++++++++ .../validateDisconnectConnectorRequest.ts | 75 ++++++++ .../connectors/validateGetConnectorsQuery.ts | 60 ++++++ .../validateGetConnectorsRequest.ts | 67 +++++++ 15 files changed, 934 insertions(+), 201 deletions(-) create mode 100644 lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts create mode 100644 lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts create mode 100644 lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts create mode 100644 lib/composio/connectors/authorizeConnectorHandler.ts create mode 100644 lib/composio/connectors/disconnectConnectorHandler.ts create mode 100644 lib/composio/connectors/getConnectorsHandler.ts rename lib/composio/connectors/{allowedArtistConnectors.ts => isAllowedArtistConnector.ts} (100%) create mode 100644 lib/composio/connectors/validateAuthorizeConnectorRequest.ts create mode 100644 lib/composio/connectors/validateDisconnectConnectorRequest.ts create mode 100644 lib/composio/connectors/validateGetConnectorsQuery.ts create mode 100644 lib/composio/connectors/validateGetConnectorsRequest.ts diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index 39a5f81e..ee830790 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -1,10 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { authorizeConnector, isAllowedArtistConnector } from "@/lib/composio/connectors"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { validateAuthorizeConnectorBody } from "@/lib/composio/connectors/validateAuthorizeConnectorBody"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { authorizeConnectorHandler } from "@/lib/composio/connectors/authorizeConnectorHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -22,7 +19,6 @@ export async function OPTIONS() { * Generate an OAuth authorization URL for a specific connector. * * Authentication: x-api-key OR Authorization Bearer token required. - * The account ID is inferred from the auth header. * * Request body: * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) @@ -32,73 +28,6 @@ export async function OPTIONS() { * * @returns The redirect URL for OAuth authorization */ -export async function POST(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - const body = await request.json(); - - const validated = validateAuthorizeConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { connector, callback_url, entity_type, entity_id } = validated; - - // Determine entity and options based on type - let composioEntityId: string; - let authConfigs: Record = {}; - - if (entity_type === "artist") { - // Verify connector is allowed for artists - if (!isAllowedArtistConnector(connector)) { - return NextResponse.json( - { error: `Connector '${connector}' is not allowed for artist connections` }, - { status: 400, headers }, - ); - } - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); - } - - composioEntityId = entity_id!; - - // Build auth configs for toolkits that need custom OAuth - if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { - authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; - } - } else { - composioEntityId = accountId; - } - - const result = await authorizeConnector(composioEntityId, connector, { - customCallbackUrl: callback_url, - entityType: entity_type, - authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, - }); - - return NextResponse.json( - { - success: true, - data: { - connector: result.connector, - redirectUrl: result.redirectUrl, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - console.error("Connector authorize error:", error); - const message = error instanceof Error ? error.message : "Failed to authorize connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } +export async function POST(request: NextRequest) { + return authorizeConnectorHandler(request); } diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index 3bfdac2d..beb44ea3 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -1,21 +1,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getConnectors, disconnectConnector, ALLOWED_ARTIST_CONNECTORS } from "@/lib/composio/connectors"; -import { validateDisconnectConnectorBody } from "@/lib/composio/connectors/validateDisconnectConnectorBody"; -import { verifyConnectorOwnership } from "@/lib/composio/connectors/verifyConnectorOwnership"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; - -/** - * Display names for connectors. - */ -const CONNECTOR_DISPLAY_NAMES: Record = { - tiktok: "TikTok", - googlesheets: "Google Sheets", - googledrive: "Google Drive", - googledocs: "Google Docs", -}; +import { getConnectorsHandler } from "@/lib/composio/connectors/getConnectorsHandler"; +import { disconnectConnectorHandler } from "@/lib/composio/connectors/disconnectConnectorHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -40,64 +27,8 @@ export async function OPTIONS() { * * @returns List of connectors with connection status */ -export async function GET(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - // Parse query params - const { searchParams } = new URL(request.url); - const entityType = (searchParams.get("entity_type") || "user") as "user" | "artist"; - const entityId = searchParams.get("entity_id"); - - // Determine the Composio entity to query - let composioEntityId: string; - let allowedToolkits: readonly string[] | undefined; - - if (entityType === "artist") { - if (!entityId) { - return NextResponse.json( - { error: "entity_id is required when entity_type is 'artist'" }, - { status: 400, headers }, - ); - } - - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, entityId); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); - } - - composioEntityId = entityId; - allowedToolkits = ALLOWED_ARTIST_CONNECTORS; - } else { - composioEntityId = accountId; - } - - const connectors = await getConnectors(composioEntityId, { - allowedToolkits, - displayNames: CONNECTOR_DISPLAY_NAMES, - }); - - return NextResponse.json( - { - success: true, - data: { - connectors, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to fetch connectors"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } +export async function GET(request: NextRequest) { + return getConnectorsHandler(request); } /** @@ -112,57 +43,6 @@ export async function GET(request: NextRequest): Promise { * * Authentication: x-api-key OR Authorization Bearer token required. */ -export async function DELETE(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - const body = await request.json(); - - const validated = validateDisconnectConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { connected_account_id, entity_type, entity_id } = validated; - - if (entity_type === "artist") { - // Verify user has access to this artist - const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this artist" }, { status: 403, headers }); - } - - // Disconnect with ownership verification against artist - await disconnectConnector(connected_account_id, { - verifyOwnershipFor: entity_id!, - }); - } else { - // Verify the connected account belongs to the authenticated user - const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); - if (!isOwner) { - return NextResponse.json( - { error: "Connected account not found or does not belong to this user" }, - { status: 403, headers }, - ); - } - - await disconnectConnector(connected_account_id); - } - - return NextResponse.json( - { - success: true, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to disconnect connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } +export async function DELETE(request: NextRequest) { + return disconnectConnectorHandler(request); } diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts new file mode 100644 index 00000000..83c2a33e --- /dev/null +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; + +// Mock dependencies +vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ + validateAccountIdHeaders: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; + +describe("validateAuthorizeConnectorRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets" }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return params for user connector", async () => { + const mockAccountId = "account-123"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets" }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockAccountId, + connector: "googlesheets", + callbackUrl: undefined, + entityType: "user", + }); + }); + + it("should return params for artist connector with access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ + connector: "tiktok", + entity_type: "artist", + entity_id: mockArtistId, + }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toMatchObject({ + composioEntityId: mockArtistId, + connector: "tiktok", + entityType: "artist", + }); + }); + + it("should return 403 for artist connector without access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ + connector: "tiktok", + entity_type: "artist", + entity_id: mockArtistId, + }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should return 400 if connector is not allowed for artists", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ + connector: "googlesheets", // Not allowed for artists + entity_type: "artist", + entity_id: "artist-456", + }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 if entity_id is missing for artist", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ + connector: "tiktok", + entity_type: "artist", + // entity_id missing + }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 if connector is missing", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({}), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts new file mode 100644 index 00000000..ee8b1286 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; + +// Mock dependencies +vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ + validateAccountIdHeaders: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +vi.mock("../verifyConnectorOwnership", () => ({ + verifyConnectorOwnership: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; + +describe("validateDisconnectConnectorRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123" }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return params for user disconnect with ownership", async () => { + const mockAccountId = "account-123"; + const mockConnectedAccountId = "ca_456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(verifyConnectorOwnership).mockResolvedValue(true); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: mockConnectedAccountId }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(verifyConnectorOwnership).toHaveBeenCalledWith(mockAccountId, mockConnectedAccountId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockAccountId, + connectedAccountId: mockConnectedAccountId, + entityType: "user", + entityId: undefined, + }); + }); + + it("should return 403 for user disconnect without ownership", async () => { + const mockAccountId = "account-123"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(verifyConnectorOwnership).mockResolvedValue(false); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_456" }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should return params for artist disconnect with access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + const mockConnectedAccountId = "ca_789"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ + connected_account_id: mockConnectedAccountId, + entity_type: "artist", + entity_id: mockArtistId, + }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockAccountId, + connectedAccountId: mockConnectedAccountId, + entityType: "artist", + entityId: mockArtistId, + }); + }); + + it("should return 403 for artist disconnect without access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ + connected_account_id: "ca_789", + entity_type: "artist", + entity_id: mockArtistId, + }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should return 400 if connected_account_id is missing", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({}), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 if entity_id is missing for artist", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ + connected_account_id: "ca_456", + entity_type: "artist", + // entity_id missing + }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts new file mode 100644 index 00000000..3e744399 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; + +// Mock dependencies +vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ + validateAccountIdHeaders: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; + +describe("validateGetConnectorsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return accountId for user entity type (default)", async () => { + const mockAccountId = "account-123"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await validateGetConnectorsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockAccountId, + }); + }); + + it("should return artistId for artist entity type with access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest( + `http://localhost/api/connectors?entity_type=artist&entity_id=${mockArtistId}`, + ); + const result = await validateGetConnectorsRequest(request); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockArtistId, + allowedToolkits: ["tiktok"], + }); + }); + + it("should return 403 for artist entity type without access", async () => { + const mockAccountId = "account-123"; + const mockArtistId = "artist-456"; + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const request = new NextRequest( + `http://localhost/api/connectors?entity_type=artist&entity_id=${mockArtistId}`, + ); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should return 400 if entity_type is artist but entity_id is missing", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest( + "http://localhost/api/connectors?entity_type=artist", + ); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should reject invalid entity_type", async () => { + vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + accountId: "account-123", + }); + + const request = new NextRequest( + "http://localhost/api/connectors?entity_type=invalid", + ); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts new file mode 100644 index 00000000..61f453f8 --- /dev/null +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -0,0 +1,53 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthorizeConnectorRequest } from "./validateAuthorizeConnectorRequest"; +import { authorizeConnector } from "./authorizeConnector"; + +/** + * Handler for POST /api/connectors/authorize. + * + * Generates an OAuth authorization URL for a specific connector. + * Supports both user and artist connectors via entity_type parameter. + * + * @param request - The incoming request + * @returns The redirect URL for OAuth authorization + */ +export async function authorizeConnectorHandler( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, body, and access in one call + const validated = await validateAuthorizeConnectorRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { composioEntityId, connector, callbackUrl, entityType, authConfigs } = validated; + + // Execute authorization + const result = await authorizeConnector(composioEntityId, connector, { + customCallbackUrl: callbackUrl, + entityType, + authConfigs, + }); + + return NextResponse.json( + { + success: true, + data: { + connector: result.connector, + redirectUrl: result.redirectUrl, + }, + }, + { status: 200, headers }, + ); + } catch (error) { + console.error("Connector authorize error:", error); + const message = + error instanceof Error ? error.message : "Failed to authorize connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts new file mode 100644 index 00000000..a46f6410 --- /dev/null +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDisconnectConnectorRequest } from "./validateDisconnectConnectorRequest"; +import { disconnectConnector } from "./disconnectConnector"; + +/** + * Handler for DELETE /api/connectors. + * + * Disconnects a connected account from Composio. + * Supports both user and artist entities via entity_type body parameter. + * + * @param request - The incoming request + * @returns Success status + */ +export async function disconnectConnectorHandler( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, body, and access in one call + const validated = await validateDisconnectConnectorRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { connectedAccountId, entityType, entityId } = validated; + + // Disconnect from Composio + if (entityType === "artist") { + await disconnectConnector(connectedAccountId, { + verifyOwnershipFor: entityId!, + }); + } else { + await disconnectConnector(connectedAccountId); + } + + return NextResponse.json({ success: true }, { status: 200, headers }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to disconnect connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts new file mode 100644 index 00000000..6c8e4ddf --- /dev/null +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetConnectorsRequest } from "./validateGetConnectorsRequest"; +import { getConnectors } from "./getConnectors"; + +/** + * Display names for connectors. + */ +const CONNECTOR_DISPLAY_NAMES: Record = { + tiktok: "TikTok", + googlesheets: "Google Sheets", + googledrive: "Google Drive", + googledocs: "Google Docs", +}; + +/** + * Handler for GET /api/connectors. + * + * Lists all available connectors and their connection status. + * Supports both user and artist entities via entity_type query parameter. + * + * @param request - The incoming request + * @returns List of connectors with connection status + */ +export async function getConnectorsHandler( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, query params, and access in one call + const validated = await validateGetConnectorsRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { composioEntityId, allowedToolkits } = validated; + + // Fetch connectors + const connectors = await getConnectors(composioEntityId, { + allowedToolkits, + displayNames: CONNECTOR_DISPLAY_NAMES, + }); + + return NextResponse.json( + { + success: true, + data: { connectors }, + }, + { status: 200, headers }, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to fetch connectors"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts index 4f3fcb76..015778ec 100644 --- a/lib/composio/connectors/index.ts +++ b/lib/composio/connectors/index.ts @@ -16,5 +16,5 @@ export { ALLOWED_ARTIST_CONNECTORS, isAllowedArtistConnector, type AllowedArtistConnector, -} from "./allowedArtistConnectors"; +} from "./isAllowedArtistConnector"; export { verifyConnectorOwnership } from "./verifyConnectorOwnership"; diff --git a/lib/composio/connectors/allowedArtistConnectors.ts b/lib/composio/connectors/isAllowedArtistConnector.ts similarity index 100% rename from lib/composio/connectors/allowedArtistConnectors.ts rename to lib/composio/connectors/isAllowedArtistConnector.ts diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index 41e60dff..3b9b5fee 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; +import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; export const authorizeConnectorBodySchema = z .object({ @@ -23,6 +24,19 @@ export const authorizeConnectorBodySchema = z message: "entity_id is required when entity_type is 'artist'", path: ["entity_id"], }, + ) + .refine( + (data) => { + // connector must be in ALLOWED_ARTIST_CONNECTORS when entity_type is "artist" + if (data.entity_type === "artist") { + return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(data.connector); + } + return true; + }, + { + message: `Connector is not allowed for artist connections. Allowed: ${ALLOWED_ARTIST_CONNECTORS.join(", ")}`, + path: ["connector"], + }, ); export type AuthorizeConnectorBody = z.infer; @@ -34,6 +48,11 @@ export type AuthorizeConnectorBody = z.infer; +} + +/** + * Validates the full POST /api/connectors/authorize request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Body validation (connector, entity_type, entity_id, allowed connector check) + * 3. Access verification (for artist entities) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateAuthorizeConnectorRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication + const authResult = await validateAccountIdHeaders(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate body (includes allowed connector check for artists) + const body = await request.json(); + const validated = validateAuthorizeConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + const { connector, callback_url, entity_type, entity_id } = validated; + + // 3. Verify access and build params + if (entity_type === "artist") { + const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + + // Build auth configs for custom OAuth + const authConfigs: Record = {}; + if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { + authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + } + + return { + composioEntityId: entity_id!, + connector, + callbackUrl: callback_url, + entityType: entity_type, + authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, + }; + } + + return { + composioEntityId: accountId, + connector, + callbackUrl: callback_url, + entityType: entity_type, + }; +} diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts new file mode 100644 index 00000000..88d7bb90 --- /dev/null +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -0,0 +1,75 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateDisconnectConnectorBody } from "./validateDisconnectConnectorBody"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; + +/** + * Validated params for disconnecting a connector. + */ +export interface DisconnectConnectorParams { + accountId: string; + connectedAccountId: string; + entityType: "user" | "artist"; + entityId?: string; +} + +/** + * Validates the full DELETE /api/connectors request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Body validation (connected_account_id, entity_type, entity_id) + * 3. Access verification (artist access or connector ownership) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateDisconnectConnectorRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication + const authResult = await validateAccountIdHeaders(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate body + const body = await request.json(); + const validated = validateDisconnectConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + const { connected_account_id, entity_type, entity_id } = validated; + + // 3. Verify access + if (entity_type === "artist") { + const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + } else { + const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); + if (!isOwner) { + return NextResponse.json( + { error: "Connected account not found or does not belong to this user" }, + { status: 403, headers }, + ); + } + } + + return { + accountId, + connectedAccountId: connected_account_id, + entityType: entity_type, + entityId: entity_id, + }; +} diff --git a/lib/composio/connectors/validateGetConnectorsQuery.ts b/lib/composio/connectors/validateGetConnectorsQuery.ts new file mode 100644 index 00000000..5e627174 --- /dev/null +++ b/lib/composio/connectors/validateGetConnectorsQuery.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const getConnectorsQuerySchema = z + .object({ + entity_type: z.enum(["user", "artist"]).optional().default("user"), + entity_id: z.string().optional(), + }) + .refine( + (data) => { + // entity_id is required when entity_type is "artist" + if (data.entity_type === "artist" && !data.entity_id) { + return false; + } + return true; + }, + { + message: "entity_id is required when entity_type is 'artist'", + path: ["entity_id"], + }, + ); + +export type GetConnectorsQuery = z.infer; + +/** + * Validates query params for GET /api/connectors. + * + * Supports both user and artist connectors: + * - User: No params required (defaults to entity_type=user) + * - Artist: entity_type=artist&entity_id=artist-uuid + * + * @param searchParams - The URL search params + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateGetConnectorsQuery( + searchParams: URLSearchParams, +): NextResponse | GetConnectorsQuery { + const queryParams = { + entity_type: searchParams.get("entity_type") ?? undefined, + entity_id: searchParams.get("entity_id") ?? undefined, + }; + + const result = getConnectorsQuerySchema.safeParse(queryParams); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts new file mode 100644 index 00000000..3fcafb3b --- /dev/null +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateGetConnectorsQuery } from "./validateGetConnectorsQuery"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; + +/** + * Validated params for getting connectors. + */ +export interface GetConnectorsParams { + composioEntityId: string; + allowedToolkits?: readonly string[]; +} + +/** + * Validates the full GET /api/connectors request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Query param validation (entity_type, entity_id) + * 3. Access verification (for artist entities) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateGetConnectorsRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication + const authResult = await validateAccountIdHeaders(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate query params + const { searchParams } = new URL(request.url); + const validated = validateGetConnectorsQuery(searchParams); + if (validated instanceof NextResponse) { + return validated; + } + const { entity_type, entity_id } = validated; + + // 3. Verify access and determine params + if (entity_type === "artist") { + const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this artist" }, + { status: 403, headers }, + ); + } + + return { + composioEntityId: entity_id!, + allowedToolkits: ALLOWED_ARTIST_CONNECTORS, + }; + } + + return { + composioEntityId: accountId, + }; +} From 3a9858b92e2c70590aa3883736c775dce601df7c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 1 Feb 2026 10:28:11 -0500 Subject: [PATCH 15/42] docs: add API route patterns and reviewer principles to CLAUDE.md - Add TDD to code principles - Document thin route files pattern (follow /api/pulses) - Document handler functions pattern - Document combined request validators (validateXxxRequest) - Add DRY guidance for entity types (use options, not duplicate files) - Add file naming convention (name after function, not constant) - Add testing requirements for API changes --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9b8ff4b4..4c286fff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,9 +140,81 @@ export async function selectTableName({ - **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. - **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities. - **KISS (Keep It Simple)**: Prefer simple solutions over clever ones. +- **TDD (Test-Driven Development)**: API changes should include unit tests. - All API routes should have JSDoc comments - Run `pnpm lint` before committing +## API Route Patterns + +Follow the `/api/pulses` pattern for all API routes: + +### Thin Route Files +Route files should be minimal - just call handler functions: + +```typescript +// app/api/example/route.ts +export async function GET(request: NextRequest) { + return getExampleHandler(request); +} + +export async function POST(request: NextRequest) { + return createExampleHandler(request); +} +``` + +### Handler Functions +Handlers live in `lib/` and orchestrate validation + business logic: + +```typescript +// lib/example/getExampleHandler.ts +export async function getExampleHandler(request: NextRequest) { + const validated = await validateGetExampleRequest(request); + if (validated instanceof NextResponse) return validated; + + // Business logic here + const result = await getExample(validated.params); + return NextResponse.json({ success: true, data: result }); +} +``` + +### Combined Request Validators +Create `validateXxxRequest` functions that handle auth + input validation + access checks: + +```typescript +// lib/example/validateGetExampleRequest.ts +export async function validateGetExampleRequest(request: NextRequest) { + // 1. Auth validation + const authResult = await validateAccountIdHeaders(request); + if (authResult instanceof NextResponse) return authResult; + + // 2. Input validation (Zod) + const validated = validateGetExampleQuery(searchParams); + if (validated instanceof NextResponse) return validated; + + // 3. Access checks (if needed) + const hasAccess = await checkAccess(authResult.accountId, validated.resourceId); + if (!hasAccess) return NextResponse.json({ error: "Access denied" }, { status: 403 }); + + return { ...validated, accountId: authResult.accountId }; +} +``` + +### DRY: Avoid Duplicate Libraries +When adding features for different entity types (e.g., user vs artist): +- **DON'T** create separate files like `getUserThing.ts` and `getArtistThing.ts` +- **DO** create one unified function with options: `getThing(entityId, { entityType: "artist" })` +- **DON'T** create separate API routes like `/api/user-things` and `/api/artist-things` +- **DO** create one unified endpoint with parameters: `/api/things?entity_type=artist&entity_id=xxx` + +### File Naming +- Name files after the **primary function** they export, not constants +- Example: `isAllowedConnector.ts` not `ALLOWED_CONNECTORS.ts` + +### Testing +- All API changes should include unit tests +- Test validators with mocked dependencies +- Cover: auth failures, validation errors, access denied, success cases + ## Authentication **Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication: From fbf424dfe1b83982d674a9b837e24ce689959522 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:30:01 -0500 Subject: [PATCH 16/42] fix: update setupToolsForRequest test for new artistId parameter - Merge test branch to sync with base - Update test to expect (accountId, artistId, roomId) signature - Add test case for when artistId is provided --- .../__tests__/setupToolsForRequest.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index 15522f62..a5bbf053 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -81,7 +81,7 @@ describe("setupToolsForRequest", () => { }); describe("Composio tools integration", () => { - it("calls getComposioTools with accountId and roomId", async () => { + it("calls getComposioTools with accountId, artistId, and roomId", async () => { const body: ChatRequestBody = { accountId: "account-123", orgId: null, @@ -92,7 +92,23 @@ describe("setupToolsForRequest", () => { await setupToolsForRequest(body); - expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "room-456"); + // getComposioTools signature: (userId, artistId?, roomId?) + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", undefined, "room-456"); + }); + + it("calls getComposioTools with artistId when provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + orgId: null, + authToken: "test-token-123", + roomId: "room-456", + artistId: "artist-789", + messages: [{ id: "1", role: "user", content: "Post to TikTok" }], + }; + + await setupToolsForRequest(body); + + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "artist-789", "room-456"); }); it("includes Composio tools in result", async () => { From fa577e766f068f7a80fb1e1875e3b33cb3466a7f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:07:08 -0500 Subject: [PATCH 17/42] refactor: extract getArtistConnectionsFromComposio to own file (SRP) --- .../getArtistConnectionsFromComposio.ts | 29 ++++++++++++++++++ lib/composio/toolRouter/getTools.ts | 30 +------------------ 2 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 lib/composio/toolRouter/getArtistConnectionsFromComposio.ts diff --git a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts new file mode 100644 index 00000000..1c4f3975 --- /dev/null +++ b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts @@ -0,0 +1,29 @@ +import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; + +/** + * Query Composio for an artist's connected accounts. + * + * Uses artistId as the Composio entity to get their connections. + * Only returns connections for ALLOWED_ARTIST_CONNECTORS (e.g., tiktok). + * + * @param artistId - The artist ID (Composio entity) + * @returns Map of toolkit slug to connected account ID + */ +export async function getArtistConnectionsFromComposio( + artistId: string +): Promise> { + // Use unified getConnectors with artist filter + const connectors = await getConnectors(artistId, { + allowedToolkits: ALLOWED_ARTIST_CONNECTORS, + }); + + // Build connections map from connected connectors + const connections: Record = {}; + for (const connector of connectors) { + if (connector.connectedAccountId) { + connections[connector.slug] = connector.connectedAccountId; + } + } + + return connections; +} diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index ebe4091f..51ae0dec 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,5 +1,5 @@ import { createToolRouterSession } from "./createSession"; -import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; +import { getArtistConnectionsFromComposio } from "./getArtistConnectionsFromComposio"; import type { Tool, ToolSet } from "ai"; /** @@ -36,34 +36,6 @@ function isValidTool(tool: unknown): tool is Tool { return hasExecute && hasSchema; } -/** - * Query Composio for an artist's connected accounts. - * - * Uses artistId as the Composio entity to get their connections. - * Only returns connections for ALLOWED_ARTIST_CONNECTORS (e.g., tiktok). - * - * @param artistId - The artist ID (Composio entity) - * @returns Map of toolkit slug to connected account ID - */ -async function getArtistConnectionsFromComposio( - artistId: string -): Promise> { - // Use unified getConnectors with artist filter - const connectors = await getConnectors(artistId, { - allowedToolkits: ALLOWED_ARTIST_CONNECTORS, - }); - - // Build connections map from connected connectors - const connections: Record = {}; - for (const connector of connectors) { - if (connector.connectedAccountId) { - connections[connector.slug] = connector.connectedAccountId; - } - } - - return connections; -} - /** * Get Composio Tool Router tools for a user. * From 93639d9f386e1098133380c3740c7a869261599f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:07:55 -0500 Subject: [PATCH 18/42] refactor: rename createSession.ts to createToolRouterSession.ts --- .../toolRouter/{createSession.ts => createToolRouterSession.ts} | 0 lib/composio/toolRouter/getTools.ts | 2 +- lib/composio/toolRouter/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/composio/toolRouter/{createSession.ts => createToolRouterSession.ts} (100%) diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createToolRouterSession.ts similarity index 100% rename from lib/composio/toolRouter/createSession.ts rename to lib/composio/toolRouter/createToolRouterSession.ts diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 51ae0dec..e582f243 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,4 +1,4 @@ -import { createToolRouterSession } from "./createSession"; +import { createToolRouterSession } from "./createToolRouterSession"; import { getArtistConnectionsFromComposio } from "./getArtistConnectionsFromComposio"; import type { Tool, ToolSet } from "ai"; diff --git a/lib/composio/toolRouter/index.ts b/lib/composio/toolRouter/index.ts index 0e3bb33c..bdcf3bb5 100644 --- a/lib/composio/toolRouter/index.ts +++ b/lib/composio/toolRouter/index.ts @@ -1,2 +1,2 @@ -export { createToolRouterSession } from "./createSession"; +export { createToolRouterSession } from "./createToolRouterSession"; export { getComposioTools } from "./getTools"; From 7eec10ae17cf9ce5d32b4c75852d67a37db60188 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:09:30 -0500 Subject: [PATCH 19/42] test: add unit tests for composio toolRouter changes --- .../getArtistConnectionsFromComposio.test.ts | 77 +++++++++ .../__tests__/getComposioTools.test.ts | 154 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts create mode 100644 lib/composio/toolRouter/__tests__/getComposioTools.test.ts diff --git a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts new file mode 100644 index 00000000..98ccab52 --- /dev/null +++ b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; + +// Mock dependencies +vi.mock("../../connectors", () => ({ + getConnectors: vi.fn(), + ALLOWED_ARTIST_CONNECTORS: ["tiktok"], +})); + +import { getConnectors } from "../../connectors"; + +describe("getArtistConnectionsFromComposio", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return empty object when no connectors are connected", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: null }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-123"); + + expect(getConnectors).toHaveBeenCalledWith("artist-123", { + allowedToolkits: ["tiktok"], + }); + expect(result).toEqual({}); + }); + + it("should return connections map for connected connectors", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-456" }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-123"); + + expect(result).toEqual({ + tiktok: "tiktok-account-456", + }); + }); + + it("should filter out connectors without connectedAccountId", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-456" }, + { slug: "instagram", connectedAccountId: null }, + { slug: "youtube", connectedAccountId: undefined }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-789"); + + expect(result).toEqual({ + tiktok: "tiktok-account-456", + }); + }); + + it("should handle multiple connected accounts", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-1" }, + { slug: "instagram", connectedAccountId: "instagram-account-2" }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-multi"); + + expect(result).toEqual({ + tiktok: "tiktok-account-1", + instagram: "instagram-account-2", + }); + }); + + it("should return empty object when getConnectors returns empty array", async () => { + vi.mocked(getConnectors).mockResolvedValue([]); + + const result = await getArtistConnectionsFromComposio("artist-empty"); + + expect(result).toEqual({}); + }); +}); diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts new file mode 100644 index 00000000..22cfa5dc --- /dev/null +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getComposioTools } from "../getTools"; + +// Mock dependencies +vi.mock("../createToolRouterSession", () => ({ + createToolRouterSession: vi.fn(), +})); + +vi.mock("../getArtistConnectionsFromComposio", () => ({ + getArtistConnectionsFromComposio: vi.fn(), +})); + +import { createToolRouterSession } from "../createToolRouterSession"; +import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; + +// Mock valid tool structure +const createMockTool = () => ({ + description: "Test tool", + inputSchema: { type: "object" }, + execute: vi.fn(), +}); + +describe("getComposioTools", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv, COMPOSIO_API_KEY: "test-api-key" }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should return empty object when COMPOSIO_API_KEY is not set", async () => { + delete process.env.COMPOSIO_API_KEY; + + const result = await getComposioTools("user-123"); + + expect(result).toEqual({}); + expect(createToolRouterSession).not.toHaveBeenCalled(); + }); + + it("should not fetch artist connections when artistId is not provided", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("user-123"); + + expect(getArtistConnectionsFromComposio).not.toHaveBeenCalled(); + expect(createToolRouterSession).toHaveBeenCalledWith( + "user-123", + undefined, + undefined, + ); + }); + + it("should fetch and pass artist connections when artistId is provided", async () => { + const mockConnections = { tiktok: "tiktok-account-456" }; + vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue(mockConnections); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("user-123", "artist-456", "room-789"); + + expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-456"); + expect(createToolRouterSession).toHaveBeenCalledWith( + "user-123", + "room-789", + mockConnections, + ); + }); + + it("should pass undefined when artist has no connections", async () => { + vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue({}); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("user-123", "artist-no-connections"); + + expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith( + "artist-no-connections", + ); + expect(createToolRouterSession).toHaveBeenCalledWith( + "user-123", + undefined, + undefined, + ); + }); + + it("should filter tools to only ALLOWED_TOOLS", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + COMPOSIO_SEARCH_TOOLS: createMockTool(), + SOME_OTHER_TOOL: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + const result = await getComposioTools("user-123"); + + expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); + expect(result).toHaveProperty("COMPOSIO_SEARCH_TOOLS"); + expect(result).not.toHaveProperty("SOME_OTHER_TOOL"); + }); + + it("should return empty object when session creation throws", async () => { + vi.mocked(createToolRouterSession).mockRejectedValue( + new Error("Bundler incompatibility"), + ); + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await getComposioTools("user-123"); + + expect(result).toEqual({}); + expect(consoleSpy).toHaveBeenCalledWith( + "Composio tools unavailable:", + "Bundler incompatibility", + ); + + consoleSpy.mockRestore(); + }); + + it("should skip invalid tools that lack required properties", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + COMPOSIO_SEARCH_TOOLS: { description: "No execute function" }, + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + const result = await getComposioTools("user-123"); + + expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); + expect(result).not.toHaveProperty("COMPOSIO_SEARCH_TOOLS"); + }); +}); From cf0616eef47ac66d61884b0f27f9ad89c24559a6 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:15:45 -0500 Subject: [PATCH 20/42] test: add comprehensive unit tests for all changed files Added tests for: - Handlers: authorizeConnectorHandler, disconnectConnectorHandler, getConnectorsHandler - Validators: validateGetConnectorsQuery, validateAuthorizeConnectorBody, validateDisconnectConnectorBody, validateCreateChatBody - Core functions: authorizeConnector, disconnectConnector, getConnectors, isAllowedArtistConnector - Utilities: getCallbackUrl, checkAccountArtistAccess, createNewRoom, createToolRouterSession Total: 120 tests passing --- lib/chat/__tests__/createNewRoom.test.ts | 140 +++++++++++++++ .../__tests__/validateCreateChatBody.test.ts | 161 ++++++++---------- lib/composio/__tests__/getCallbackUrl.test.ts | 67 ++++++++ .../__tests__/authorizeConnector.test.ts | 104 +++++++++++ .../authorizeConnectorHandler.test.ts | 108 ++++++++++++ .../__tests__/disconnectConnector.test.ts | 91 ++++++++++ .../disconnectConnectorHandler.test.ts | 95 +++++++++++ .../__tests__/getConnectors.test.ts | 142 +++++++++++++++ .../__tests__/getConnectorsHandler.test.ts | 96 +++++++++++ .../isAllowedArtistConnector.test.ts | 37 ++++ .../validateAuthorizeConnectorBody.test.ts | 111 ++++++++++++ .../validateDisconnectConnectorBody.test.ts | 85 +++++++++ .../validateGetConnectorsQuery.test.ts | 75 ++++++++ .../__tests__/createToolRouterSession.test.ts | 77 +++++++++ .../checkAccountArtistAccess.test.ts | 155 +++++++++++++++++ 15 files changed, 1455 insertions(+), 89 deletions(-) create mode 100644 lib/chat/__tests__/createNewRoom.test.ts create mode 100644 lib/composio/__tests__/getCallbackUrl.test.ts create mode 100644 lib/composio/connectors/__tests__/authorizeConnector.test.ts create mode 100644 lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts create mode 100644 lib/composio/connectors/__tests__/disconnectConnector.test.ts create mode 100644 lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts create mode 100644 lib/composio/connectors/__tests__/getConnectors.test.ts create mode 100644 lib/composio/connectors/__tests__/getConnectorsHandler.test.ts create mode 100644 lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts create mode 100644 lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts create mode 100644 lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts create mode 100644 lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts create mode 100644 lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts create mode 100644 lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts diff --git a/lib/chat/__tests__/createNewRoom.test.ts b/lib/chat/__tests__/createNewRoom.test.ts new file mode 100644 index 00000000..241a40c4 --- /dev/null +++ b/lib/chat/__tests__/createNewRoom.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createNewRoom } from "../createNewRoom"; + +vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ + upsertRoom: vi.fn(), +})); + +vi.mock("@/lib/chat/generateChatTitle", () => ({ + generateChatTitle: vi.fn(), +})); + +vi.mock("@/lib/telegram/sendNewConversationNotification", () => ({ + sendNewConversationNotification: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; +import { generateChatTitle } from "@/lib/chat/generateChatTitle"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +describe("createNewRoom", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateChatTitle).mockResolvedValue("Generated Title"); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + vi.mocked(upsertRoom).mockResolvedValue(undefined); + vi.mocked(sendNewConversationNotification).mockResolvedValue(undefined); + }); + + it("should create room with generated title", async () => { + const lastMessage = { + id: "msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Hello, how are you?" }], + }; + + await createNewRoom({ + accountId: "account-123", + roomId: "room-456", + lastMessage, + }); + + expect(generateChatTitle).toHaveBeenCalledWith("Hello, how are you?"); + expect(upsertRoom).toHaveBeenCalledWith({ + account_id: "account-123", + topic: "Generated Title", + artist_id: undefined, + id: "room-456", + }); + }); + + it("should include artist_id when provided", async () => { + const lastMessage = { + id: "msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Hello" }], + }; + + await createNewRoom({ + accountId: "account-123", + roomId: "room-456", + artistId: "artist-789", + lastMessage, + }); + + expect(upsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + artist_id: "artist-789", + }), + ); + }); + + it("should send notification with account email when available", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([ + { email: "user@example.com" }, + ]); + + const lastMessage = { + id: "msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Hello" }], + }; + + await createNewRoom({ + accountId: "account-123", + roomId: "room-456", + lastMessage, + }); + + expect(sendNewConversationNotification).toHaveBeenCalledWith({ + accountId: "account-123", + email: "user@example.com", + conversationId: "room-456", + topic: "Generated Title", + firstMessage: "Hello", + }); + }); + + it("should send notification with empty email when no account email", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const lastMessage = { + id: "msg-1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Hello" }], + }; + + await createNewRoom({ + accountId: "account-123", + roomId: "room-456", + lastMessage, + }); + + expect(sendNewConversationNotification).toHaveBeenCalledWith( + expect.objectContaining({ + email: "", + }), + ); + }); + + it("should handle message with empty text", async () => { + const lastMessage = { + id: "msg-1", + role: "user" as const, + parts: [], + }; + + await createNewRoom({ + accountId: "account-123", + roomId: "room-456", + lastMessage, + }); + + expect(generateChatTitle).toHaveBeenCalledWith(""); + }); +}); diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts index 0ec4fbe2..abbd9a59 100644 --- a/lib/chats/__tests__/validateCreateChatBody.test.ts +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -1,124 +1,107 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { NextResponse } from "next/server"; -import { validateCreateChatBody, createChatBodySchema } from "../validateCreateChatBody"; +import { validateCreateChatBody } from "../validateCreateChatBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); describe("validateCreateChatBody", () => { - describe("artistId validation", () => { - it("accepts valid UUID for artistId", () => { - const result = validateCreateChatBody({ - artistId: "123e4567-e89b-12d3-a456-426614174000", - }); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).artistId).toBe("123e4567-e89b-12d3-a456-426614174000"); - }); + it("should accept empty body (all fields optional)", () => { + const result = validateCreateChatBody({}); - it("rejects invalid UUID for artistId", () => { - const result = validateCreateChatBody({ - artistId: "invalid-uuid", - }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({}); + }); - expect(result).toBeInstanceOf(NextResponse); + it("should accept valid artistId UUID", () => { + const result = validateCreateChatBody({ + artistId: "550e8400-e29b-41d4-a716-446655440000", }); - it("accepts missing artistId (optional)", () => { - const result = validateCreateChatBody({}); - - expect(result).not.toBeInstanceOf(NextResponse); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + artistId: "550e8400-e29b-41d4-a716-446655440000", }); }); - describe("chatId validation", () => { - it("accepts valid UUID for chatId", () => { - const result = validateCreateChatBody({ - chatId: "123e4567-e89b-12d3-a456-426614174000", - }); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).chatId).toBe("123e4567-e89b-12d3-a456-426614174000"); + it("should accept valid chatId UUID", () => { + const result = validateCreateChatBody({ + chatId: "550e8400-e29b-41d4-a716-446655440000", }); - it("rejects invalid UUID for chatId", () => { - const result = validateCreateChatBody({ - chatId: "invalid-uuid", - }); - - expect(result).toBeInstanceOf(NextResponse); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + chatId: "550e8400-e29b-41d4-a716-446655440000", }); }); - describe("accountId validation", () => { - it("accepts valid UUID for accountId", () => { - const result = validateCreateChatBody({ - accountId: "123e4567-e89b-12d3-a456-426614174000", - }); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).accountId).toBe("123e4567-e89b-12d3-a456-426614174000"); + it("should accept valid accountId UUID", () => { + const result = validateCreateChatBody({ + accountId: "550e8400-e29b-41d4-a716-446655440000", }); - it("rejects invalid UUID for accountId", () => { - const result = validateCreateChatBody({ - accountId: "invalid-uuid", - }); - - expect(result).toBeInstanceOf(NextResponse); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "550e8400-e29b-41d4-a716-446655440000", }); + }); - it("accepts missing accountId (optional)", () => { - const result = validateCreateChatBody({ - artistId: "123e4567-e89b-12d3-a456-426614174000", - }); + it("should accept optional firstMessage", () => { + const result = validateCreateChatBody({ + firstMessage: "Hello, world!", + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).accountId).toBeUndefined(); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + firstMessage: "Hello, world!", }); }); - describe("schema type inference", () => { - it("schema should include accountId as optional UUID field", () => { - const validBody = { - artistId: "123e4567-e89b-12d3-a456-426614174000", - chatId: "123e4567-e89b-12d3-a456-426614174001", - accountId: "123e4567-e89b-12d3-a456-426614174002", - }; - - const result = createChatBodySchema.safeParse(validBody); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.accountId).toBe("123e4567-e89b-12d3-a456-426614174002"); - } + it("should accept all valid fields together", () => { + const result = validateCreateChatBody({ + artistId: "550e8400-e29b-41d4-a716-446655440001", + chatId: "550e8400-e29b-41d4-a716-446655440002", + accountId: "550e8400-e29b-41d4-a716-446655440003", + firstMessage: "Hello", }); - }); - describe("firstMessage validation", () => { - it("accepts valid string for firstMessage", () => { - const result = validateCreateChatBody({ - artistId: "123e4567-e89b-12d3-a456-426614174000", - firstMessage: "What marketing strategies should I use?", - }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + artistId: "550e8400-e29b-41d4-a716-446655440001", + chatId: "550e8400-e29b-41d4-a716-446655440002", + accountId: "550e8400-e29b-41d4-a716-446655440003", + firstMessage: "Hello", + }); + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).firstMessage).toBe("What marketing strategies should I use?"); + it("should return 400 for invalid artistId UUID format", () => { + const result = validateCreateChatBody({ + artistId: "not-a-uuid", }); - it("accepts missing firstMessage (optional)", () => { - const result = validateCreateChatBody({ - artistId: "123e4567-e89b-12d3-a456-426614174000", - }); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).firstMessage).toBeUndefined(); + it("should return 400 for invalid chatId UUID format", () => { + const result = validateCreateChatBody({ + chatId: "invalid", }); - it("accepts empty string for firstMessage", () => { - const result = validateCreateChatBody({ - artistId: "123e4567-e89b-12d3-a456-426614174000", - firstMessage: "", - }); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).firstMessage).toBe(""); + it("should return 400 for invalid accountId UUID format", () => { + const result = validateCreateChatBody({ + accountId: "12345", }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); }); }); diff --git a/lib/composio/__tests__/getCallbackUrl.test.ts b/lib/composio/__tests__/getCallbackUrl.test.ts new file mode 100644 index 00000000..4ce99ff3 --- /dev/null +++ b/lib/composio/__tests__/getCallbackUrl.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCallbackUrl } from "../getCallbackUrl"; + +vi.mock("../getFrontendBaseUrl", () => ({ + getFrontendBaseUrl: vi.fn(() => "https://chat.recoupable.com"), +})); + +describe("getCallbackUrl", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("connectors destination", () => { + it("should return settings/connectors URL with connected param", () => { + const result = getCallbackUrl({ destination: "connectors" }); + + expect(result).toBe( + "https://chat.recoupable.com/settings/connectors?connected=true", + ); + }); + }); + + describe("artist-connectors destination", () => { + it("should return chat URL with artist_connected and toolkit params", () => { + const result = getCallbackUrl({ + destination: "artist-connectors", + artistId: "artist-123", + toolkit: "tiktok", + }); + + expect(result).toBe( + "https://chat.recoupable.com/chat?artist_connected=artist-123&toolkit=tiktok", + ); + }); + + it("should handle missing artistId and toolkit gracefully", () => { + const result = getCallbackUrl({ + destination: "artist-connectors", + }); + + expect(result).toBe( + "https://chat.recoupable.com/chat?artist_connected=undefined&toolkit=undefined", + ); + }); + }); + + describe("chat destination", () => { + it("should return chat URL with roomId", () => { + const result = getCallbackUrl({ + destination: "chat", + roomId: "room-456", + }); + + expect(result).toBe( + "https://chat.recoupable.com/chat/room-456?connected=true", + ); + }); + + it("should return base chat URL without roomId", () => { + const result = getCallbackUrl({ + destination: "chat", + }); + + expect(result).toBe("https://chat.recoupable.com/chat?connected=true"); + }); + }); +}); diff --git a/lib/composio/connectors/__tests__/authorizeConnector.test.ts b/lib/composio/connectors/__tests__/authorizeConnector.test.ts new file mode 100644 index 00000000..4a67a61b --- /dev/null +++ b/lib/composio/connectors/__tests__/authorizeConnector.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { authorizeConnector } from "../authorizeConnector"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +vi.mock("../../getCallbackUrl", () => ({ + getCallbackUrl: vi.fn(), +})); + +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; + +describe("authorizeConnector", () => { + const mockAuthorize = vi.fn(); + const mockSession = { authorize: mockAuthorize }; + const mockComposio = { create: vi.fn(() => mockSession) }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue(mockComposio); + vi.mocked(getCallbackUrl).mockReturnValue("https://example.com/callback"); + mockAuthorize.mockResolvedValue({ + redirectUrl: "https://oauth.example.com/authorize", + }); + }); + + it("should authorize user connector with default options", async () => { + const result = await authorizeConnector("user-123", "googlesheets"); + + expect(getComposioClient).toHaveBeenCalled(); + expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "connectors" }); + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + manageConnections: { callbackUrl: "https://example.com/callback" }, + }); + expect(mockAuthorize).toHaveBeenCalledWith("googlesheets"); + expect(result).toEqual({ + connector: "googlesheets", + redirectUrl: "https://oauth.example.com/authorize", + }); + }); + + it("should use custom callback URL when provided", async () => { + await authorizeConnector("user-123", "googlesheets", { + customCallbackUrl: "https://custom.example.com/callback", + }); + + expect(getCallbackUrl).not.toHaveBeenCalled(); + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + manageConnections: { callbackUrl: "https://custom.example.com/callback" }, + }); + }); + + it("should build artist callback URL for artist entity type", async () => { + vi.mocked(getCallbackUrl).mockReturnValue( + "https://example.com/chat?artist_connected=artist-456&toolkit=tiktok", + ); + + await authorizeConnector("artist-456", "tiktok", { + entityType: "artist", + }); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "artist-connectors", + artistId: "artist-456", + toolkit: "tiktok", + }); + }); + + it("should pass auth configs when provided", async () => { + await authorizeConnector("user-123", "tiktok", { + authConfigs: { tiktok: "ac_12345" }, + }); + + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + authConfigs: { tiktok: "ac_12345" }, + manageConnections: { callbackUrl: "https://example.com/callback" }, + }); + }); + + it("should not pass empty auth configs", async () => { + await authorizeConnector("user-123", "googlesheets", { + authConfigs: {}, + }); + + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + manageConnections: { callbackUrl: "https://example.com/callback" }, + }); + }); + + it("should use toolkit option for callback URL when provided", async () => { + await authorizeConnector("artist-456", "tiktok", { + entityType: "artist", + toolkit: "custom-toolkit", + }); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "artist-connectors", + artistId: "artist-456", + toolkit: "custom-toolkit", + }); + }); +}); diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts new file mode 100644 index 00000000..0c49ccd3 --- /dev/null +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { authorizeConnectorHandler } from "../authorizeConnectorHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +vi.mock("../validateAuthorizeConnectorRequest", () => ({ + validateAuthorizeConnectorRequest: vi.fn(), +})); + +vi.mock("../authorizeConnector", () => ({ + authorizeConnector: vi.fn(), +})); + +import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; +import { authorizeConnector } from "../authorizeConnector"; + +describe("authorizeConnectorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error when request validation fails", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + + expect(result.status).toBe(401); + }); + + it("should return redirect URL on successful authorization", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + composioEntityId: "user-123", + connector: "googlesheets", + entityType: "user", + }); + + vi.mocked(authorizeConnector).mockResolvedValue({ + connector: "googlesheets", + redirectUrl: "https://oauth.example.com/authorize", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.connector).toBe("googlesheets"); + expect(body.data.redirectUrl).toBe("https://oauth.example.com/authorize"); + }); + + it("should pass correct options for artist entity type", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + composioEntityId: "artist-456", + connector: "tiktok", + callbackUrl: "https://example.com/callback", + entityType: "artist", + authConfigs: { tiktok: "ac_123" }, + }); + + vi.mocked(authorizeConnector).mockResolvedValue({ + connector: "tiktok", + redirectUrl: "https://oauth.tiktok.com/authorize", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + }); + await authorizeConnectorHandler(request); + + expect(authorizeConnector).toHaveBeenCalledWith("artist-456", "tiktok", { + customCallbackUrl: "https://example.com/callback", + entityType: "artist", + authConfigs: { tiktok: "ac_123" }, + }); + }); + + it("should return 500 when authorizeConnector throws", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + composioEntityId: "user-123", + connector: "googlesheets", + entityType: "user", + }); + + vi.mocked(authorizeConnector).mockRejectedValue( + new Error("Composio API error"), + ); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Composio API error"); + }); +}); diff --git a/lib/composio/connectors/__tests__/disconnectConnector.test.ts b/lib/composio/connectors/__tests__/disconnectConnector.test.ts new file mode 100644 index 00000000..95e8dd99 --- /dev/null +++ b/lib/composio/connectors/__tests__/disconnectConnector.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { disconnectConnector } from "../disconnectConnector"; + +vi.mock("../../getComposioApiKey", () => ({ + getComposioApiKey: vi.fn(() => "test-api-key"), +})); + +vi.mock("../getConnectors", () => ({ + getConnectors: vi.fn(), +})); + +import { getConnectors } from "../getConnectors"; + +describe("disconnectConnector", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("should disconnect connector successfully", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response); + + const result = await disconnectConnector("ca_12345"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://backend.composio.dev/api/v3/connected_accounts/ca_12345", + { + method: "DELETE", + headers: { + "x-api-key": "test-api-key", + "Content-Type": "application/json", + }, + }, + ); + expect(result).toEqual({ success: true }); + }); + + it("should throw when API returns error", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve("Not found"), + } as Response); + + await expect(disconnectConnector("ca_12345")).rejects.toThrow( + "Failed to disconnect (404): Not found", + ); + }); + + it("should verify ownership before disconnecting when requested", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "ca_12345" }, + ]); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response); + + await disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }); + + expect(getConnectors).toHaveBeenCalledWith("artist-456"); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should throw when ownership verification fails", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "ca_different" }, + ]); + + await expect( + disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }), + ).rejects.toThrow("Connection not found for this entity"); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should throw when entity has no connections", async () => { + vi.mocked(getConnectors).mockResolvedValue([]); + + await expect( + disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }), + ).rejects.toThrow("Connection not found for this entity"); + }); +}); diff --git a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts new file mode 100644 index 00000000..6ba09ec8 --- /dev/null +++ b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { disconnectConnectorHandler } from "../disconnectConnectorHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +vi.mock("../validateDisconnectConnectorRequest", () => ({ + validateDisconnectConnectorRequest: vi.fn(), +})); + +vi.mock("../disconnectConnector", () => ({ + disconnectConnector: vi.fn(), +})); + +import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; +import { disconnectConnector } from "../disconnectConnector"; + +describe("disconnectConnectorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error when request validation fails", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + + expect(result.status).toBe(401); + }); + + it("should disconnect user connector successfully", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_12345", + entityType: "user", + }); + + vi.mocked(disconnectConnector).mockResolvedValue({ success: true }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.success).toBe(true); + expect(disconnectConnector).toHaveBeenCalledWith("ca_12345"); + }); + + it("should disconnect artist connector with ownership verification", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_12345", + entityType: "artist", + entityId: "artist-456", + }); + + vi.mocked(disconnectConnector).mockResolvedValue({ success: true }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + await disconnectConnectorHandler(request); + + expect(disconnectConnector).toHaveBeenCalledWith("ca_12345", { + verifyOwnershipFor: "artist-456", + }); + }); + + it("should return 500 when disconnectConnector throws", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_12345", + entityType: "user", + }); + + vi.mocked(disconnectConnector).mockRejectedValue( + new Error("Connection not found"), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Connection not found"); + }); +}); diff --git a/lib/composio/connectors/__tests__/getConnectors.test.ts b/lib/composio/connectors/__tests__/getConnectors.test.ts new file mode 100644 index 00000000..a56240a4 --- /dev/null +++ b/lib/composio/connectors/__tests__/getConnectors.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getConnectors } from "../getConnectors"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +import { getComposioClient } from "../../client"; + +describe("getConnectors", () => { + const mockToolkits = vi.fn(); + const mockSession = { toolkits: mockToolkits }; + const mockComposio = { create: vi.fn(() => mockSession) }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue(mockComposio); + }); + + it("should return connectors list with connection status", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "googlesheets", + name: "Google Sheets", + connection: { isActive: true, connectedAccount: { id: "ca_123" } }, + }, + { + slug: "googledrive", + name: "Google Drive", + connection: null, + }, + ], + }); + + const result = await getConnectors("user-123"); + + expect(getComposioClient).toHaveBeenCalled(); + expect(mockComposio.create).toHaveBeenCalledWith("user-123", undefined); + expect(result).toEqual([ + { + slug: "googlesheets", + name: "Google Sheets", + isConnected: true, + connectedAccountId: "ca_123", + }, + { + slug: "googledrive", + name: "Google Drive", + isConnected: false, + connectedAccountId: undefined, + }, + ]); + }); + + it("should filter by allowed toolkits when provided", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "tiktok", + name: "TikTok", + connection: { isActive: true, connectedAccount: { id: "ca_456" } }, + }, + ], + }); + + await getConnectors("artist-456", { + allowedToolkits: ["tiktok"], + }); + + expect(mockComposio.create).toHaveBeenCalledWith("artist-456", { + toolkits: ["tiktok"], + }); + }); + + it("should use custom display names when provided", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "tiktok", + name: "tiktok", + connection: null, + }, + ], + }); + + const result = await getConnectors("user-123", { + displayNames: { tiktok: "TikTok" }, + }); + + expect(result[0].name).toBe("TikTok"); + }); + + it("should add missing allowed toolkits that are not in Composio response", async () => { + mockToolkits.mockResolvedValue({ + items: [], // Composio returns no toolkits + }); + + const result = await getConnectors("artist-456", { + allowedToolkits: ["tiktok", "instagram"], + displayNames: { tiktok: "TikTok", instagram: "Instagram" }, + }); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { slug: "tiktok", name: "TikTok", isConnected: false, connectedAccountId: undefined }, + { slug: "instagram", name: "Instagram", isConnected: false, connectedAccountId: undefined }, + ]); + }); + + it("should maintain order of allowed toolkits", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { slug: "instagram", name: "Instagram", connection: null }, + { slug: "tiktok", name: "TikTok", connection: null }, + ], + }); + + const result = await getConnectors("user-123", { + allowedToolkits: ["tiktok", "instagram"], + }); + + expect(result[0].slug).toBe("tiktok"); + expect(result[1].slug).toBe("instagram"); + }); + + it("should handle inactive connections", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "googlesheets", + name: "Google Sheets", + connection: { isActive: false, connectedAccount: { id: "ca_123" } }, + }, + ], + }); + + const result = await getConnectors("user-123"); + + expect(result[0].isConnected).toBe(false); + }); +}); diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts new file mode 100644 index 00000000..f698af39 --- /dev/null +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getConnectorsHandler } from "../getConnectorsHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +vi.mock("../validateGetConnectorsRequest", () => ({ + validateGetConnectorsRequest: vi.fn(), +})); + +vi.mock("../getConnectors", () => ({ + getConnectors: vi.fn(), +})); + +import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; +import { getConnectors } from "../getConnectors"; + +describe("getConnectorsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error when request validation fails", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + + expect(result.status).toBe(401); + }); + + it("should return connectors list for user", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + composioEntityId: "user-123", + }); + + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "googlesheets", name: "Google Sheets", isConnected: true }, + { slug: "googledrive", name: "Google Drive", isConnected: false }, + ]); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.connectors).toHaveLength(2); + expect(body.data.connectors[0].slug).toBe("googlesheets"); + }); + + it("should pass allowedToolkits for artist entity type", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + composioEntityId: "artist-456", + allowedToolkits: ["tiktok"], + }); + + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true }, + ]); + + const request = new NextRequest( + "http://localhost/api/connectors?entity_type=artist&entity_id=artist-456", + ); + await getConnectorsHandler(request); + + expect(getConnectors).toHaveBeenCalledWith("artist-456", { + allowedToolkits: ["tiktok"], + displayNames: { + tiktok: "TikTok", + googlesheets: "Google Sheets", + googledrive: "Google Drive", + googledocs: "Google Docs", + }, + }); + }); + + it("should return 500 when getConnectors throws", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + composioEntityId: "user-123", + }); + + vi.mocked(getConnectors).mockRejectedValue(new Error("Composio API error")); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Composio API error"); + }); +}); diff --git a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts new file mode 100644 index 00000000..f3577796 --- /dev/null +++ b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { + isAllowedArtistConnector, + ALLOWED_ARTIST_CONNECTORS, +} from "../isAllowedArtistConnector"; + +describe("isAllowedArtistConnector", () => { + it("should return true for 'tiktok'", () => { + expect(isAllowedArtistConnector("tiktok")).toBe(true); + }); + + it("should return false for connectors not in ALLOWED_ARTIST_CONNECTORS", () => { + expect(isAllowedArtistConnector("googlesheets")).toBe(false); + expect(isAllowedArtistConnector("googledrive")).toBe(false); + expect(isAllowedArtistConnector("instagram")).toBe(false); + expect(isAllowedArtistConnector("random")).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isAllowedArtistConnector("")).toBe(false); + }); + + it("should be case-sensitive", () => { + expect(isAllowedArtistConnector("TikTok")).toBe(false); + expect(isAllowedArtistConnector("TIKTOK")).toBe(false); + }); +}); + +describe("ALLOWED_ARTIST_CONNECTORS", () => { + it("should include tiktok", () => { + expect(ALLOWED_ARTIST_CONNECTORS).toContain("tiktok"); + }); + + it("should be a readonly array", () => { + expect(Array.isArray(ALLOWED_ARTIST_CONNECTORS)).toBe(true); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts new file mode 100644 index 00000000..9b969dd7 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAuthorizeConnectorBody } from "../validateAuthorizeConnectorBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateAuthorizeConnectorBody", () => { + it("should accept valid user connector request", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + entity_type: "user", + }); + }); + + it("should accept valid artist connector request with tiktok", () => { + const result = validateAuthorizeConnectorBody({ + connector: "tiktok", + entity_type: "artist", + entity_id: "artist-123", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "tiktok", + entity_type: "artist", + entity_id: "artist-123", + }); + }); + + it("should accept optional callback_url", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + callback_url: "https://example.com/callback", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + entity_type: "user", + callback_url: "https://example.com/callback", + }); + }); + + it("should return 400 when connector is missing", () => { + const result = validateAuthorizeConnectorBody({}); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 when connector is empty", () => { + const result = validateAuthorizeConnectorBody({ connector: "" }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 when entity_type=artist but entity_id is missing", () => { + const result = validateAuthorizeConnectorBody({ + connector: "tiktok", + entity_type: "artist", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 when entity_type=artist but connector is not allowed", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + entity_type: "artist", + entity_id: "artist-123", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid callback_url format", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + callback_url: "not-a-valid-url", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid entity_type", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + entity_type: "invalid", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts new file mode 100644 index 00000000..bf353d8f --- /dev/null +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateDisconnectConnectorBody } from "../validateDisconnectConnectorBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateDisconnectConnectorBody", () => { + it("should accept valid user disconnect request", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connected_account_id: "ca_12345", + entity_type: "user", + }); + }); + + it("should accept valid artist disconnect request", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + entity_type: "artist", + entity_id: "artist-123", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connected_account_id: "ca_12345", + entity_type: "artist", + entity_id: "artist-123", + }); + }); + + it("should return 400 when connected_account_id is missing", () => { + const result = validateDisconnectConnectorBody({}); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 when connected_account_id is empty", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 when entity_type=artist but entity_id is missing", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + entity_type: "artist", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid entity_type", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + entity_type: "invalid", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should default entity_type to user when not provided", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as { entity_type: string }).entity_type).toBe("user"); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts new file mode 100644 index 00000000..0eae970a --- /dev/null +++ b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateGetConnectorsQuery } from "../validateGetConnectorsQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateGetConnectorsQuery", () => { + it("should return default user entity_type when no params provided", () => { + const searchParams = new URLSearchParams(); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + entity_type: "user", + }); + }); + + it("should accept entity_type=user", () => { + const searchParams = new URLSearchParams({ entity_type: "user" }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + entity_type: "user", + }); + }); + + it("should accept entity_type=artist with entity_id", () => { + const searchParams = new URLSearchParams({ + entity_type: "artist", + entity_id: "artist-123", + }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + entity_type: "artist", + entity_id: "artist-123", + }); + }); + + it("should return 400 when entity_type=artist but entity_id is missing", () => { + const searchParams = new URLSearchParams({ entity_type: "artist" }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid entity_type", () => { + const searchParams = new URLSearchParams({ entity_type: "invalid" }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should ignore entity_id when entity_type is user", () => { + const searchParams = new URLSearchParams({ + entity_type: "user", + entity_id: "some-id", + }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + entity_type: "user", + entity_id: "some-id", + }); + }); +}); diff --git a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts new file mode 100644 index 00000000..07e3425e --- /dev/null +++ b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createToolRouterSession } from "../createToolRouterSession"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +vi.mock("../../getCallbackUrl", () => ({ + getCallbackUrl: vi.fn(), +})); + +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; + +describe("createToolRouterSession", () => { + const mockSession = { tools: vi.fn() }; + const mockComposio = { create: vi.fn(() => mockSession) }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue(mockComposio); + vi.mocked(getCallbackUrl).mockReturnValue("https://example.com/chat?connected=true"); + }); + + it("should create session with enabled toolkits", async () => { + await createToolRouterSession("user-123"); + + expect(getComposioClient).toHaveBeenCalled(); + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: undefined, + }); + }); + + it("should include roomId in callback URL", async () => { + await createToolRouterSession("user-123", "room-456"); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "chat", + roomId: "room-456", + }); + }); + + it("should pass artist connections when provided", async () => { + const artistConnections = { + tiktok: "tiktok-account-789", + }; + + await createToolRouterSession("user-123", undefined, artistConnections); + + expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: artistConnections, + }); + }); + + it("should return session object", async () => { + const result = await createToolRouterSession("user-123"); + + expect(result).toBe(mockSession); + }); + + it("should handle undefined roomId", async () => { + await createToolRouterSession("user-123", undefined); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "chat", + roomId: undefined, + }); + }); +}); diff --git a/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts b/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts new file mode 100644 index 00000000..1bd26825 --- /dev/null +++ b/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("checkAccountArtistAccess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return true when account has direct access to artist", async () => { + const mockSelect = vi.fn().mockReturnThis(); + const mockEq = vi.fn().mockReturnThis(); + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: { artist_id: "artist-123" }, + error: null, + }); + + vi.mocked(supabase.from).mockReturnValue({ + select: mockSelect, + eq: mockEq, + maybeSingle: mockMaybeSingle, + } as never); + + mockSelect.mockReturnThis(); + mockEq.mockReturnValue({ eq: mockEq, maybeSingle: mockMaybeSingle }); + + const result = await checkAccountArtistAccess("account-123", "artist-123"); + + expect(supabase.from).toHaveBeenCalledWith("account_artist_ids"); + expect(result).toBe(true); + }); + + it("should return true when account and artist share an organization", async () => { + // First call - direct access check (returns null) + const mockDirectAccess = vi.fn().mockResolvedValue({ + data: null, + error: null, + }); + + // Second call - artist orgs + const mockArtistOrgs = vi.fn().mockResolvedValue({ + data: [{ organization_id: "org-1" }], + error: null, + }); + + // Third call - user org access + const mockUserOrgAccess = vi.fn().mockResolvedValue({ + data: [{ organization_id: "org-1" }], + error: null, + }); + + let callCount = 0; + vi.mocked(supabase.from).mockImplementation((table: string) => { + callCount++; + if (table === "account_artist_ids") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: mockDirectAccess, + }), + }), + }), + } as never; + } else if (table === "artist_organization_ids") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue(mockArtistOrgs()), + }), + } as never; + } else if (table === "account_organization_ids") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockUserOrgAccess()), + }), + }), + }), + } as never; + } + return {} as never; + }); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(result).toBe(true); + }); + + it("should return false when direct access check errors (fail closed)", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: new Error("Database error"), + }), + }), + }), + }), + } as never); + + const result = await checkAccountArtistAccess("account-123", "artist-123"); + + expect(result).toBe(false); + }); + + it("should return false when account has no access", async () => { + // Direct access - none + const mockDirectAccess = vi.fn().mockResolvedValue({ + data: null, + error: null, + }); + + // Artist has no orgs + const mockArtistOrgs = vi.fn().mockResolvedValue({ + data: [], + error: null, + }); + + vi.mocked(supabase.from).mockImplementation((table: string) => { + if (table === "account_artist_ids") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: mockDirectAccess, + }), + }), + }), + } as never; + } else if (table === "artist_organization_ids") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue(mockArtistOrgs()), + }), + } as never; + } + return {} as never; + }); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(result).toBe(false); + }); +}); From 92b173399dac550f53b73a16e30b15919b9891ae Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:11:37 -0500 Subject: [PATCH 21/42] fix: revert validateCreateChatBody.test.ts to original (no changes needed) --- .../__tests__/validateCreateChatBody.test.ts | 161 ++++++++++-------- 1 file changed, 89 insertions(+), 72 deletions(-) diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts index abbd9a59..0ec4fbe2 100644 --- a/lib/chats/__tests__/validateCreateChatBody.test.ts +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -1,107 +1,124 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { NextResponse } from "next/server"; -import { validateCreateChatBody } from "../validateCreateChatBody"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => new Headers()), -})); +import { validateCreateChatBody, createChatBodySchema } from "../validateCreateChatBody"; describe("validateCreateChatBody", () => { - it("should accept empty body (all fields optional)", () => { - const result = validateCreateChatBody({}); + describe("artistId validation", () => { + it("accepts valid UUID for artistId", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).artistId).toBe("123e4567-e89b-12d3-a456-426614174000"); + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({}); - }); + it("rejects invalid UUID for artistId", () => { + const result = validateCreateChatBody({ + artistId: "invalid-uuid", + }); - it("should accept valid artistId UUID", () => { - const result = validateCreateChatBody({ - artistId: "550e8400-e29b-41d4-a716-446655440000", + expect(result).toBeInstanceOf(NextResponse); }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - artistId: "550e8400-e29b-41d4-a716-446655440000", + it("accepts missing artistId (optional)", () => { + const result = validateCreateChatBody({}); + + expect(result).not.toBeInstanceOf(NextResponse); }); }); - it("should accept valid chatId UUID", () => { - const result = validateCreateChatBody({ - chatId: "550e8400-e29b-41d4-a716-446655440000", - }); + describe("chatId validation", () => { + it("accepts valid UUID for chatId", () => { + const result = validateCreateChatBody({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - chatId: "550e8400-e29b-41d4-a716-446655440000", + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).chatId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); - }); - it("should accept valid accountId UUID", () => { - const result = validateCreateChatBody({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - }); + it("rejects invalid UUID for chatId", () => { + const result = validateCreateChatBody({ + chatId: "invalid-uuid", + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - accountId: "550e8400-e29b-41d4-a716-446655440000", + expect(result).toBeInstanceOf(NextResponse); }); }); - it("should accept optional firstMessage", () => { - const result = validateCreateChatBody({ - firstMessage: "Hello, world!", - }); + describe("accountId validation", () => { + it("accepts valid UUID for accountId", () => { + const result = validateCreateChatBody({ + accountId: "123e4567-e89b-12d3-a456-426614174000", + }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - firstMessage: "Hello, world!", + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); - }); - it("should accept all valid fields together", () => { - const result = validateCreateChatBody({ - artistId: "550e8400-e29b-41d4-a716-446655440001", - chatId: "550e8400-e29b-41d4-a716-446655440002", - accountId: "550e8400-e29b-41d4-a716-446655440003", - firstMessage: "Hello", + it("rejects invalid UUID for accountId", () => { + const result = validateCreateChatBody({ + accountId: "invalid-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - artistId: "550e8400-e29b-41d4-a716-446655440001", - chatId: "550e8400-e29b-41d4-a716-446655440002", - accountId: "550e8400-e29b-41d4-a716-446655440003", - firstMessage: "Hello", + it("accepts missing accountId (optional)", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBeUndefined(); }); }); - it("should return 400 for invalid artistId UUID format", () => { - const result = validateCreateChatBody({ - artistId: "not-a-uuid", + describe("schema type inference", () => { + it("schema should include accountId as optional UUID field", () => { + const validBody = { + artistId: "123e4567-e89b-12d3-a456-426614174000", + chatId: "123e4567-e89b-12d3-a456-426614174001", + accountId: "123e4567-e89b-12d3-a456-426614174002", + }; + + const result = createChatBodySchema.safeParse(validBody); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.accountId).toBe("123e4567-e89b-12d3-a456-426614174002"); + } }); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); }); - it("should return 400 for invalid chatId UUID format", () => { - const result = validateCreateChatBody({ - chatId: "invalid", + describe("firstMessage validation", () => { + it("accepts valid string for firstMessage", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + firstMessage: "What marketing strategies should I use?", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBe("What marketing strategies should I use?"); }); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); + it("accepts missing firstMessage (optional)", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); - it("should return 400 for invalid accountId UUID format", () => { - const result = validateCreateChatBody({ - accountId: "12345", + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBeUndefined(); }); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); + it("accepts empty string for firstMessage", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + firstMessage: "", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).firstMessage).toBe(""); + }); }); }); From 426382f53a8dbb3f55c1fa3fb0813836c0ecbaf3 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:16:33 -0500 Subject: [PATCH 22/42] refactor: remove entity_type, use entity_id presence for connection type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove entity_type parameter from API (authorize, disconnect, get connectors) - Infer isEntityConnection from entity_id presence instead - Update callback URLs: entity connections → /chat?artist_connected=... - Rename 'user' → 'account' in tests for consistency - All 892 tests pass --- app/api/connectors/authorize/route.ts | 1 + app/api/connectors/route.ts | 9 +- lib/composio/__tests__/getCallbackUrl.test.ts | 55 +++------ .../__tests__/authorizeConnector.test.ts | 106 ++++++++---------- .../authorizeConnectorHandler.test.ts | 88 +++++++++------ .../__tests__/disconnectConnector.test.ts | 4 +- .../disconnectConnectorHandler.test.ts | 59 +++++----- .../__tests__/getConnectors.test.ts | 14 +-- .../__tests__/getConnectorsHandler.test.ts | 20 ++-- .../isAllowedArtistConnector.test.ts | 5 +- .../validateAuthorizeConnectorBody.test.ts | 34 ++---- .../validateAuthorizeConnectorRequest.test.ts | 96 +++++----------- .../validateDisconnectConnectorBody.test.ts | 35 +----- ...validateDisconnectConnectorRequest.test.ts | 102 ++++------------- .../validateGetConnectorsQuery.test.ts | 53 ++------- .../validateGetConnectorsRequest.test.ts | 50 +++------ lib/composio/connectors/authorizeConnector.ts | 37 +++--- .../connectors/authorizeConnectorHandler.ts | 13 +-- .../connectors/disconnectConnector.ts | 8 +- .../connectors/disconnectConnectorHandler.ts | 17 ++- lib/composio/connectors/getConnectors.ts | 13 +-- .../connectors/getConnectorsHandler.ts | 9 +- lib/composio/connectors/index.ts | 11 +- .../validateAuthorizeConnectorBody.ts | 39 +++---- .../validateAuthorizeConnectorRequest.ts | 28 +++-- .../validateDisconnectConnectorBody.ts | 31 ++--- .../validateDisconnectConnectorRequest.ts | 23 ++-- .../connectors/validateGetConnectorsQuery.ts | 27 +---- .../validateGetConnectorsRequest.ts | 20 ++-- .../connectors/verifyConnectorOwnership.ts | 6 +- lib/composio/getCallbackUrl.ts | 24 ++-- .../__tests__/createToolRouterSession.test.ts | 20 ++-- .../getArtistConnectionsFromComposio.test.ts | 8 +- .../__tests__/getComposioTools.test.ts | 46 +++----- .../toolRouter/createToolRouterSession.ts | 2 +- .../getArtistConnectionsFromComposio.ts | 2 +- lib/composio/toolRouter/getTools.ts | 2 +- 37 files changed, 405 insertions(+), 712 deletions(-) diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index ee830790..c35d228b 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -26,6 +26,7 @@ export async function OPTIONS() { * - entity_type: "user" (default) or "artist" * - entity_id: Required when entity_type is "artist" * + * @param request * @returns The redirect URL for OAuth authorization */ export async function POST(request: NextRequest) { diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index beb44ea3..77a30e48 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -25,6 +25,7 @@ export async function OPTIONS() { * * Authentication: x-api-key OR Authorization Bearer token required. * + * @param request * @returns List of connectors with connection status */ export async function GET(request: NextRequest) { @@ -37,11 +38,13 @@ export async function GET(request: NextRequest) { * Disconnect a connected account from Composio. * * Body: - * - connected_account_id (required): The connected account ID to disconnect - * - entity_type (optional): "user" (default) or "artist" - * - entity_id (required when entity_type=artist): The artist ID + * - connected_account_id (required): The connected account ID to disconnect + * - entity_type (optional): "user" (default) or "artist" + * - entity_id (required when entity_type=artist): The artist ID * * Authentication: x-api-key OR Authorization Bearer token required. + * + * @param request */ export async function DELETE(request: NextRequest) { return disconnectConnectorHandler(request); diff --git a/lib/composio/__tests__/getCallbackUrl.test.ts b/lib/composio/__tests__/getCallbackUrl.test.ts index 4ce99ff3..268f43f7 100644 --- a/lib/composio/__tests__/getCallbackUrl.test.ts +++ b/lib/composio/__tests__/getCallbackUrl.test.ts @@ -11,57 +11,32 @@ describe("getCallbackUrl", () => { }); describe("connectors destination", () => { - it("should return settings/connectors URL with connected param", () => { - const result = getCallbackUrl({ destination: "connectors" }); - - expect(result).toBe( - "https://chat.recoupable.com/settings/connectors?connected=true", - ); + it("should return settings/connectors URL", () => { + const url = getCallbackUrl({ destination: "connectors" }); + expect(url).toBe("https://chat.recoupable.com/settings/connectors?connected=true"); }); }); - describe("artist-connectors destination", () => { - it("should return chat URL with artist_connected and toolkit params", () => { - const result = getCallbackUrl({ - destination: "artist-connectors", - artistId: "artist-123", + describe("entity-connectors destination", () => { + it("should return chat URL with entity and toolkit params", () => { + const url = getCallbackUrl({ + destination: "entity-connectors", + entityId: "entity-123", toolkit: "tiktok", }); - - expect(result).toBe( - "https://chat.recoupable.com/chat?artist_connected=artist-123&toolkit=tiktok", - ); - }); - - it("should handle missing artistId and toolkit gracefully", () => { - const result = getCallbackUrl({ - destination: "artist-connectors", - }); - - expect(result).toBe( - "https://chat.recoupable.com/chat?artist_connected=undefined&toolkit=undefined", - ); + expect(url).toBe("https://chat.recoupable.com/chat?artist_connected=entity-123&toolkit=tiktok"); }); }); describe("chat destination", () => { - it("should return chat URL with roomId", () => { - const result = getCallbackUrl({ - destination: "chat", - roomId: "room-456", - }); - - expect(result).toBe( - "https://chat.recoupable.com/chat/room-456?connected=true", - ); + it("should return chat URL without roomId", () => { + const url = getCallbackUrl({ destination: "chat" }); + expect(url).toBe("https://chat.recoupable.com/chat?connected=true"); }); - it("should return base chat URL without roomId", () => { - const result = getCallbackUrl({ - destination: "chat", - }); - - expect(result).toBe("https://chat.recoupable.com/chat?connected=true"); + it("should return chat URL with roomId", () => { + const url = getCallbackUrl({ destination: "chat", roomId: "room-123" }); + expect(url).toBe("https://chat.recoupable.com/chat/room-123?connected=true"); }); }); }); diff --git a/lib/composio/connectors/__tests__/authorizeConnector.test.ts b/lib/composio/connectors/__tests__/authorizeConnector.test.ts index 4a67a61b..8bb16666 100644 --- a/lib/composio/connectors/__tests__/authorizeConnector.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnector.test.ts @@ -1,104 +1,94 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { authorizeConnector } from "../authorizeConnector"; +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; + vi.mock("../../client", () => ({ getComposioClient: vi.fn(), })); vi.mock("../../getCallbackUrl", () => ({ - getCallbackUrl: vi.fn(), + getCallbackUrl: vi.fn(() => "https://app.example.com/settings/connectors?connected=true"), })); -import { getComposioClient } from "../../client"; -import { getCallbackUrl } from "../../getCallbackUrl"; - describe("authorizeConnector", () => { const mockAuthorize = vi.fn(); - const mockSession = { authorize: mockAuthorize }; - const mockComposio = { create: vi.fn(() => mockSession) }; + const mockCreate = vi.fn(); + const mockClient = { + create: mockCreate, + }; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getComposioClient).mockResolvedValue(mockComposio); - vi.mocked(getCallbackUrl).mockReturnValue("https://example.com/callback"); - mockAuthorize.mockResolvedValue({ - redirectUrl: "https://oauth.example.com/authorize", - }); + mockCreate.mockResolvedValue({ authorize: mockAuthorize }); + mockAuthorize.mockResolvedValue({ redirectUrl: "https://oauth.example.com/auth" }); + vi.mocked(getComposioClient).mockResolvedValue(mockClient as never); }); - it("should authorize user connector with default options", async () => { - const result = await authorizeConnector("user-123", "googlesheets"); + it("should generate OAuth URL for connector", async () => { + const result = await authorizeConnector("account-123", "googlesheets"); - expect(getComposioClient).toHaveBeenCalled(); - expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "connectors" }); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { - manageConnections: { callbackUrl: "https://example.com/callback" }, + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, }); expect(mockAuthorize).toHaveBeenCalledWith("googlesheets"); expect(result).toEqual({ connector: "googlesheets", - redirectUrl: "https://oauth.example.com/authorize", + redirectUrl: "https://oauth.example.com/auth", }); }); - it("should use custom callback URL when provided", async () => { - await authorizeConnector("user-123", "googlesheets", { - customCallbackUrl: "https://custom.example.com/callback", - }); + it("should use connectors destination when not entity connection", async () => { + await authorizeConnector("account-123", "googlesheets", { isEntityConnection: false }); - expect(getCallbackUrl).not.toHaveBeenCalled(); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { - manageConnections: { callbackUrl: "https://custom.example.com/callback" }, - }); + expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "connectors" }); }); - it("should build artist callback URL for artist entity type", async () => { - vi.mocked(getCallbackUrl).mockReturnValue( - "https://example.com/chat?artist_connected=artist-456&toolkit=tiktok", - ); - - await authorizeConnector("artist-456", "tiktok", { - entityType: "artist", - }); + it("should use entity-connectors destination when entity connection", async () => { + await authorizeConnector("entity-456", "tiktok", { isEntityConnection: true }); expect(getCallbackUrl).toHaveBeenCalledWith({ - destination: "artist-connectors", - artistId: "artist-456", + destination: "entity-connectors", + entityId: "entity-456", toolkit: "tiktok", }); }); - it("should pass auth configs when provided", async () => { - await authorizeConnector("user-123", "tiktok", { - authConfigs: { tiktok: "ac_12345" }, - }); + it("should use custom callback URL when provided", async () => { + const customUrl = "https://custom.example.com/callback"; + await authorizeConnector("account-123", "googlesheets", { customCallbackUrl: customUrl }); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { - authConfigs: { tiktok: "ac_12345" }, - manageConnections: { callbackUrl: "https://example.com/callback" }, + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: customUrl, + }, }); + // getCallbackUrl should not be called when custom URL is provided + expect(getCallbackUrl).not.toHaveBeenCalled(); }); - it("should not pass empty auth configs", async () => { - await authorizeConnector("user-123", "googlesheets", { - authConfigs: {}, - }); + it("should include auth configs when provided", async () => { + const authConfigs = { tiktok: "ac_12345" }; + await authorizeConnector("entity-123", "tiktok", { authConfigs }); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { - manageConnections: { callbackUrl: "https://example.com/callback" }, + expect(mockCreate).toHaveBeenCalledWith("entity-123", { + authConfigs, + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, }); }); - it("should use toolkit option for callback URL when provided", async () => { - await authorizeConnector("artist-456", "tiktok", { - entityType: "artist", - toolkit: "custom-toolkit", - }); + it("should not include authConfigs if empty object", async () => { + await authorizeConnector("account-123", "googlesheets", { authConfigs: {} }); - expect(getCallbackUrl).toHaveBeenCalledWith({ - destination: "artist-connectors", - artistId: "artist-456", - toolkit: "custom-toolkit", + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, }); }); }); diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts index 0c49ccd3..be29db65 100644 --- a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -2,9 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { authorizeConnectorHandler } from "../authorizeConnectorHandler"; -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => new Headers()), -})); +import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; +import { authorizeConnector } from "../authorizeConnector"; vi.mock("../validateAuthorizeConnectorRequest", () => ({ validateAuthorizeConnectorRequest: vi.fn(), @@ -14,17 +13,18 @@ vi.mock("../authorizeConnector", () => ({ authorizeConnector: vi.fn(), })); -import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; -import { authorizeConnector } from "../authorizeConnector"; +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); describe("authorizeConnectorHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("should return validation error when request validation fails", async () => { + it("should return validation error if validation fails", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue( - NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + NextResponse.json({ error: "Invalid request" }, { status: 400 }), ); const request = new NextRequest("http://localhost/api/connectors/authorize", { @@ -32,45 +32,46 @@ describe("authorizeConnectorHandler", () => { }); const result = await authorizeConnectorHandler(request); - expect(result.status).toBe(401); + expect(result.status).toBe(400); }); - it("should return redirect URL on successful authorization", async () => { + it("should call authorizeConnector with validated params for account connection", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "user-123", + composioEntityId: "account-123", connector: "googlesheets", - entityType: "user", + isEntityConnection: false, }); - vi.mocked(authorizeConnector).mockResolvedValue({ connector: "googlesheets", - redirectUrl: "https://oauth.example.com/authorize", + redirectUrl: "https://oauth.example.com/auth", }); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", }); const result = await authorizeConnectorHandler(request); - const body = await result.json(); + expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { + customCallbackUrl: undefined, + authConfigs: undefined, + isEntityConnection: false, + }); expect(result.status).toBe(200); + const body = await result.json(); expect(body.success).toBe(true); - expect(body.data.connector).toBe("googlesheets"); - expect(body.data.redirectUrl).toBe("https://oauth.example.com/authorize"); + expect(body.data.redirectUrl).toBe("https://oauth.example.com/auth"); }); - it("should pass correct options for artist entity type", async () => { + it("should call authorizeConnector with isEntityConnection true for entity connection", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "artist-456", + composioEntityId: "entity-456", connector: "tiktok", - callbackUrl: "https://example.com/callback", - entityType: "artist", authConfigs: { tiktok: "ac_123" }, + isEntityConnection: true, }); - vi.mocked(authorizeConnector).mockResolvedValue({ connector: "tiktok", - redirectUrl: "https://oauth.tiktok.com/authorize", + redirectUrl: "https://oauth.example.com/auth", }); const request = new NextRequest("http://localhost/api/connectors/authorize", { @@ -78,31 +79,52 @@ describe("authorizeConnectorHandler", () => { }); await authorizeConnectorHandler(request); - expect(authorizeConnector).toHaveBeenCalledWith("artist-456", "tiktok", { - customCallbackUrl: "https://example.com/callback", - entityType: "artist", + expect(authorizeConnector).toHaveBeenCalledWith("entity-456", "tiktok", { + customCallbackUrl: undefined, authConfigs: { tiktok: "ac_123" }, + isEntityConnection: true, }); }); - it("should return 500 when authorizeConnector throws", async () => { + it("should pass through custom callbackUrl", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "user-123", + composioEntityId: "account-123", + connector: "googlesheets", + callbackUrl: "https://custom.example.com/callback", + isEntityConnection: false, + }); + vi.mocked(authorizeConnector).mockResolvedValue({ connector: "googlesheets", - entityType: "user", + redirectUrl: "https://oauth.example.com/auth", }); - vi.mocked(authorizeConnector).mockRejectedValue( - new Error("Composio API error"), - ); + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + }); + await authorizeConnectorHandler(request); + + expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { + customCallbackUrl: "https://custom.example.com/callback", + authConfigs: undefined, + isEntityConnection: false, + }); + }); + + it("should return 500 on error", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + composioEntityId: "account-123", + connector: "googlesheets", + isEntityConnection: false, + }); + vi.mocked(authorizeConnector).mockRejectedValue(new Error("OAuth failed")); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", }); const result = await authorizeConnectorHandler(request); - const body = await result.json(); expect(result.status).toBe(500); - expect(body.error).toBe("Composio API error"); + const body = await result.json(); + expect(body.error).toBe("OAuth failed"); }); }); diff --git a/lib/composio/connectors/__tests__/disconnectConnector.test.ts b/lib/composio/connectors/__tests__/disconnectConnector.test.ts index 95e8dd99..2f7e3161 100644 --- a/lib/composio/connectors/__tests__/disconnectConnector.test.ts +++ b/lib/composio/connectors/__tests__/disconnectConnector.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { disconnectConnector } from "../disconnectConnector"; +import { getConnectors } from "../getConnectors"; + vi.mock("../../getComposioApiKey", () => ({ getComposioApiKey: vi.fn(() => "test-api-key"), })); @@ -9,8 +11,6 @@ vi.mock("../getConnectors", () => ({ getConnectors: vi.fn(), })); -import { getConnectors } from "../getConnectors"; - describe("disconnectConnector", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts index 6ba09ec8..91644c6f 100644 --- a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts @@ -2,9 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { disconnectConnectorHandler } from "../disconnectConnectorHandler"; -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => new Headers()), -})); +import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; +import { disconnectConnector } from "../disconnectConnector"; vi.mock("../validateDisconnectConnectorRequest", () => ({ validateDisconnectConnectorRequest: vi.fn(), @@ -14,17 +13,18 @@ vi.mock("../disconnectConnector", () => ({ disconnectConnector: vi.fn(), })); -import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; -import { disconnectConnector } from "../disconnectConnector"; +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); describe("disconnectConnectorHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("should return validation error when request validation fails", async () => { + it("should return validation error if validation fails", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue( - NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + NextResponse.json({ error: "Invalid request" }, { status: 400 }), ); const request = new NextRequest("http://localhost/api/connectors", { @@ -32,64 +32,57 @@ describe("disconnectConnectorHandler", () => { }); const result = await disconnectConnectorHandler(request); - expect(result.status).toBe(401); + expect(result.status).toBe(400); }); - it("should disconnect user connector successfully", async () => { + it("should call disconnectConnector without options when no entityId", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ - connectedAccountId: "ca_12345", - entityType: "user", + connectedAccountId: "ca_123", }); - - vi.mocked(disconnectConnector).mockResolvedValue({ success: true }); + vi.mocked(disconnectConnector).mockResolvedValue(undefined); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", }); const result = await disconnectConnectorHandler(request); - const body = await result.json(); + expect(disconnectConnector).toHaveBeenCalledWith("ca_123"); expect(result.status).toBe(200); + const body = await result.json(); expect(body.success).toBe(true); - expect(disconnectConnector).toHaveBeenCalledWith("ca_12345"); }); - it("should disconnect artist connector with ownership verification", async () => { + it("should call disconnectConnector with verifyOwnershipFor when entityId provided", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ - connectedAccountId: "ca_12345", - entityType: "artist", - entityId: "artist-456", + connectedAccountId: "ca_123", + entityId: "entity-456", }); - - vi.mocked(disconnectConnector).mockResolvedValue({ success: true }); + vi.mocked(disconnectConnector).mockResolvedValue(undefined); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", }); - await disconnectConnectorHandler(request); + const result = await disconnectConnectorHandler(request); - expect(disconnectConnector).toHaveBeenCalledWith("ca_12345", { - verifyOwnershipFor: "artist-456", + expect(disconnectConnector).toHaveBeenCalledWith("ca_123", { + verifyOwnershipFor: "entity-456", }); + expect(result.status).toBe(200); }); - it("should return 500 when disconnectConnector throws", async () => { + it("should return 500 on error", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ - connectedAccountId: "ca_12345", - entityType: "user", + connectedAccountId: "ca_123", }); - - vi.mocked(disconnectConnector).mockRejectedValue( - new Error("Connection not found"), - ); + vi.mocked(disconnectConnector).mockRejectedValue(new Error("Disconnect failed")); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", }); const result = await disconnectConnectorHandler(request); - const body = await result.json(); expect(result.status).toBe(500); - expect(body.error).toBe("Connection not found"); + const body = await result.json(); + expect(body.error).toBe("Disconnect failed"); }); }); diff --git a/lib/composio/connectors/__tests__/getConnectors.test.ts b/lib/composio/connectors/__tests__/getConnectors.test.ts index a56240a4..f4cd545c 100644 --- a/lib/composio/connectors/__tests__/getConnectors.test.ts +++ b/lib/composio/connectors/__tests__/getConnectors.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getConnectors } from "../getConnectors"; +import { getComposioClient } from "../../client"; + vi.mock("../../client", () => ({ getComposioClient: vi.fn(), })); -import { getComposioClient } from "../../client"; - describe("getConnectors", () => { const mockToolkits = vi.fn(); const mockSession = { toolkits: mockToolkits }; @@ -33,10 +33,10 @@ describe("getConnectors", () => { ], }); - const result = await getConnectors("user-123"); + const result = await getConnectors("account-123"); expect(getComposioClient).toHaveBeenCalled(); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", undefined); + expect(mockComposio.create).toHaveBeenCalledWith("account-123", undefined); expect(result).toEqual([ { slug: "googlesheets", @@ -84,7 +84,7 @@ describe("getConnectors", () => { ], }); - const result = await getConnectors("user-123", { + const result = await getConnectors("account-123", { displayNames: { tiktok: "TikTok" }, }); @@ -116,7 +116,7 @@ describe("getConnectors", () => { ], }); - const result = await getConnectors("user-123", { + const result = await getConnectors("account-123", { allowedToolkits: ["tiktok", "instagram"], }); @@ -135,7 +135,7 @@ describe("getConnectors", () => { ], }); - const result = await getConnectors("user-123"); + const result = await getConnectors("account-123"); expect(result[0].isConnected).toBe(false); }); diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index f698af39..bf3b4837 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getConnectorsHandler } from "../getConnectorsHandler"; +import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; +import { getConnectors } from "../getConnectors"; + vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => new Headers()), })); @@ -14,9 +17,6 @@ vi.mock("../getConnectors", () => ({ getConnectors: vi.fn(), })); -import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; -import { getConnectors } from "../getConnectors"; - describe("getConnectorsHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -33,9 +33,9 @@ describe("getConnectorsHandler", () => { expect(result.status).toBe(401); }); - it("should return connectors list for user", async () => { + it("should return connectors list for account", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "user-123", + composioEntityId: "account-123", }); vi.mocked(getConnectors).mockResolvedValue([ @@ -53,9 +53,9 @@ describe("getConnectorsHandler", () => { expect(body.data.connectors[0].slug).toBe("googlesheets"); }); - it("should pass allowedToolkits for artist entity type", async () => { + it("should pass allowedToolkits when entity_id is provided", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "artist-456", + composioEntityId: "entity-456", allowedToolkits: ["tiktok"], }); @@ -64,11 +64,11 @@ describe("getConnectorsHandler", () => { ]); const request = new NextRequest( - "http://localhost/api/connectors?entity_type=artist&entity_id=artist-456", + "http://localhost/api/connectors?entity_id=entity-456", ); await getConnectorsHandler(request); - expect(getConnectors).toHaveBeenCalledWith("artist-456", { + expect(getConnectors).toHaveBeenCalledWith("entity-456", { allowedToolkits: ["tiktok"], displayNames: { tiktok: "TikTok", @@ -81,7 +81,7 @@ describe("getConnectorsHandler", () => { it("should return 500 when getConnectors throws", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "user-123", + composioEntityId: "account-123", }); vi.mocked(getConnectors).mockRejectedValue(new Error("Composio API error")); diff --git a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts index f3577796..f0b223b0 100644 --- a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts +++ b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - isAllowedArtistConnector, - ALLOWED_ARTIST_CONNECTORS, -} from "../isAllowedArtistConnector"; +import { isAllowedArtistConnector, ALLOWED_ARTIST_CONNECTORS } from "../isAllowedArtistConnector"; describe("isAllowedArtistConnector", () => { it("should return true for 'tiktok'", () => { diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts index 9b969dd7..fa89e87b 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("validateAuthorizeConnectorBody", () => { - it("should accept valid user connector request", () => { + it("should accept valid connector request without entity_id", () => { const result = validateAuthorizeConnectorBody({ connector: "googlesheets", }); @@ -15,22 +15,19 @@ describe("validateAuthorizeConnectorBody", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connector: "googlesheets", - entity_type: "user", }); }); - it("should accept valid artist connector request with tiktok", () => { + it("should accept valid connector request with entity_id for allowed connector", () => { const result = validateAuthorizeConnectorBody({ connector: "tiktok", - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connector: "tiktok", - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); }); @@ -43,7 +40,6 @@ describe("validateAuthorizeConnectorBody", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connector: "googlesheets", - entity_type: "user", callback_url: "https://example.com/callback", }); }); @@ -64,22 +60,10 @@ describe("validateAuthorizeConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 when entity_type=artist but entity_id is missing", () => { - const result = validateAuthorizeConnectorBody({ - connector: "tiktok", - entity_type: "artist", - }); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should return 400 when entity_type=artist but connector is not allowed", () => { + it("should return 400 when entity_id is provided but connector is not allowed", () => { const result = validateAuthorizeConnectorBody({ connector: "googlesheets", - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).toBeInstanceOf(NextResponse); @@ -98,10 +82,10 @@ describe("validateAuthorizeConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 for invalid entity_type", () => { + it("should return 400 for invalid entity_id UUID format", () => { const result = validateAuthorizeConnectorBody({ - connector: "googlesheets", - entity_type: "invalid", + connector: "tiktok", + entity_id: "not-a-uuid", }); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index 83c2a33e..c0682545 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; -// Mock dependencies +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; + vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ validateAccountIdHeaders: vi.fn(), })); @@ -15,9 +17,6 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => new Headers()), })); -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; - describe("validateAuthorizeConnectorRequest", () => { beforeEach(() => { vi.clearAllMocks(); @@ -39,7 +38,7 @@ describe("validateAuthorizeConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should return params for user connector", async () => { + it("should return accountId as composioEntityId with isEntityConnection=false when no entity_id", async () => { const mockAccountId = "account-123"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, @@ -56,13 +55,13 @@ describe("validateAuthorizeConnectorRequest", () => { composioEntityId: mockAccountId, connector: "googlesheets", callbackUrl: undefined, - entityType: "user", + isEntityConnection: false, }); }); - it("should return params for artist connector with access", async () => { + it("should return entity_id as composioEntityId with isEntityConnection=true when entity_id provided", async () => { const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, }); @@ -70,26 +69,24 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ - connector: "tiktok", - entity_type: "artist", - entity_id: mockArtistId, - }), + body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toMatchObject({ - composioEntityId: mockArtistId, + expect(result).toEqual({ + composioEntityId: mockEntityId, connector: "tiktok", - entityType: "artist", + callbackUrl: undefined, + authConfigs: undefined, + isEntityConnection: true, }); }); - it("should return 403 for artist connector without access", async () => { + it("should return 403 when entity_id provided but no access", async () => { const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, }); @@ -97,11 +94,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ - connector: "tiktok", - entity_type: "artist", - entity_id: mockArtistId, - }), + body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); @@ -110,59 +103,28 @@ describe("validateAuthorizeConnectorRequest", () => { expect(response.status).toBe(403); }); - it("should return 400 if connector is not allowed for artists", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", - }); - - const request = new NextRequest("http://localhost/api/connectors/authorize", { - method: "POST", - body: JSON.stringify({ - connector: "googlesheets", // Not allowed for artists - entity_type: "artist", - entity_id: "artist-456", - }), - }); - const result = await validateAuthorizeConnectorRequest(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); + it("should include TikTok auth config when connector is tiktok and env var is set", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const originalEnv = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = "ac_test123"; - it("should return 400 if entity_id is missing for artist", async () => { vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", + accountId: mockAccountId, }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ - connector: "tiktok", - entity_type: "artist", - // entity_id missing - }), + body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should return 400 if connector is missing", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", - }); - - const request = new NextRequest("http://localhost/api/connectors/authorize", { - method: "POST", - body: JSON.stringify({}), + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as { authConfigs?: Record }).authConfigs).toEqual({ + tiktok: "ac_test123", }); - const result = await validateAuthorizeConnectorRequest(request); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); + process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = originalEnv; }); }); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts index bf353d8f..f8e10918 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("validateDisconnectConnectorBody", () => { - it("should accept valid user disconnect request", () => { + it("should accept valid disconnect request without entity_id", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", }); @@ -15,22 +15,19 @@ describe("validateDisconnectConnectorBody", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connected_account_id: "ca_12345", - entity_type: "user", }); }); - it("should accept valid artist disconnect request", () => { + it("should accept valid disconnect request with entity_id", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connected_account_id: "ca_12345", - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); }); @@ -52,34 +49,14 @@ describe("validateDisconnectConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 when entity_type=artist but entity_id is missing", () => { + it("should return 400 for invalid entity_id UUID format", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", - entity_type: "artist", + entity_id: "not-a-uuid", }); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(400); }); - - it("should return 400 for invalid entity_type", () => { - const result = validateDisconnectConnectorBody({ - connected_account_id: "ca_12345", - entity_type: "invalid", - }); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should default entity_type to user when not provided", () => { - const result = validateDisconnectConnectorBody({ - connected_account_id: "ca_12345", - }); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { entity_type: string }).entity_type).toBe("user"); - }); }); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts index ee8b1286..d1cdaa2b 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; -// Mock dependencies +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; + vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ validateAccountIdHeaders: vi.fn(), })); @@ -19,10 +22,6 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => new Headers()), })); -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; - describe("validateDisconnectConnectorRequest", () => { beforeEach(() => { vi.clearAllMocks(); @@ -44,40 +43,35 @@ describe("validateDisconnectConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should return params for user disconnect with ownership", async () => { - const mockAccountId = "account-123"; - const mockConnectedAccountId = "ca_456"; + it("should verify ownership when no entity_id provided", async () => { vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: mockAccountId, + accountId: "account-123", }); vi.mocked(verifyConnectorOwnership).mockResolvedValue(true); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: mockConnectedAccountId }), + body: JSON.stringify({ connected_account_id: "ca_123" }), }); const result = await validateDisconnectConnectorRequest(request); - expect(verifyConnectorOwnership).toHaveBeenCalledWith(mockAccountId, mockConnectedAccountId); + expect(verifyConnectorOwnership).toHaveBeenCalledWith("account-123", "ca_123"); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - accountId: mockAccountId, - connectedAccountId: mockConnectedAccountId, - entityType: "user", + connectedAccountId: "ca_123", entityId: undefined, }); }); - it("should return 403 for user disconnect without ownership", async () => { - const mockAccountId = "account-123"; + it("should return 403 when ownership verification fails", async () => { vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: mockAccountId, + accountId: "account-123", }); vi.mocked(verifyConnectorOwnership).mockResolvedValue(false); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_456" }), + body: JSON.stringify({ connected_account_id: "ca_123" }), }); const result = await validateDisconnectConnectorRequest(request); @@ -86,50 +80,38 @@ describe("validateDisconnectConnectorRequest", () => { expect(response.status).toBe(403); }); - it("should return params for artist disconnect with access", async () => { - const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; - const mockConnectedAccountId = "ca_789"; + it("should check entity access when entity_id provided", async () => { + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: mockAccountId, + accountId: "account-123", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ - connected_account_id: mockConnectedAccountId, - entity_type: "artist", - entity_id: mockArtistId, - }), + body: JSON.stringify({ connected_account_id: "ca_123", entity_id: mockEntityId }), }); const result = await validateDisconnectConnectorRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", mockEntityId); + expect(verifyConnectorOwnership).not.toHaveBeenCalled(); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - accountId: mockAccountId, - connectedAccountId: mockConnectedAccountId, - entityType: "artist", - entityId: mockArtistId, + connectedAccountId: "ca_123", + entityId: mockEntityId, }); }); - it("should return 403 for artist disconnect without access", async () => { - const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; + it("should return 403 when entity access denied", async () => { + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: mockAccountId, + accountId: "account-123", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ - connected_account_id: "ca_789", - entity_type: "artist", - entity_id: mockArtistId, - }), + body: JSON.stringify({ connected_account_id: "ca_123", entity_id: mockEntityId }), }); const result = await validateDisconnectConnectorRequest(request); @@ -137,40 +119,4 @@ describe("validateDisconnectConnectorRequest", () => { const response = result as NextResponse; expect(response.status).toBe(403); }); - - it("should return 400 if connected_account_id is missing", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", - }); - - const request = new NextRequest("http://localhost/api/connectors", { - method: "DELETE", - body: JSON.stringify({}), - }); - const result = await validateDisconnectConnectorRequest(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should return 400 if entity_id is missing for artist", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", - }); - - const request = new NextRequest("http://localhost/api/connectors", { - method: "DELETE", - body: JSON.stringify({ - connected_account_id: "ca_456", - entity_type: "artist", - // entity_id missing - }), - }); - const result = await validateDisconnectConnectorRequest(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); }); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts index 0eae970a..616c919f 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts @@ -7,69 +7,34 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("validateGetConnectorsQuery", () => { - it("should return default user entity_type when no params provided", () => { + it("should return empty object when no params provided", () => { const searchParams = new URLSearchParams(); const result = validateGetConnectorsQuery(searchParams); expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - entity_type: "user", - }); - }); - - it("should accept entity_type=user", () => { - const searchParams = new URLSearchParams({ entity_type: "user" }); - const result = validateGetConnectorsQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - entity_type: "user", - }); + expect(result).toEqual({}); }); - it("should accept entity_type=artist with entity_id", () => { + it("should accept valid entity_id UUID", () => { const searchParams = new URLSearchParams({ - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); const result = validateGetConnectorsQuery(searchParams); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - entity_type: "artist", - entity_id: "artist-123", + entity_id: "550e8400-e29b-41d4-a716-446655440000", }); }); - it("should return 400 when entity_type=artist but entity_id is missing", () => { - const searchParams = new URLSearchParams({ entity_type: "artist" }); - const result = validateGetConnectorsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should return 400 for invalid entity_type", () => { - const searchParams = new URLSearchParams({ entity_type: "invalid" }); + it("should return 400 for invalid entity_id UUID format", () => { + const searchParams = new URLSearchParams({ + entity_id: "not-a-uuid", + }); const result = validateGetConnectorsQuery(searchParams); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(400); }); - - it("should ignore entity_id when entity_type is user", () => { - const searchParams = new URLSearchParams({ - entity_type: "user", - entity_id: "some-id", - }); - const result = validateGetConnectorsQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - entity_type: "user", - entity_id: "some-id", - }); - }); }); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index 3e744399..06a26748 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; -// Mock dependencies +import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; + vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ validateAccountIdHeaders: vi.fn(), })); @@ -15,9 +17,6 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => new Headers()), })); -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; - describe("validateGetConnectorsRequest", () => { beforeEach(() => { vi.clearAllMocks(); @@ -36,7 +35,7 @@ describe("validateGetConnectorsRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId for user entity type (default)", async () => { + it("should return accountId as composioEntityId when no entity_id provided", async () => { const mockAccountId = "account-123"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, @@ -51,38 +50,34 @@ describe("validateGetConnectorsRequest", () => { }); }); - it("should return artistId for artist entity type with access", async () => { + it("should return entity_id as composioEntityId with allowedToolkits when entity_id provided", async () => { const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); - const request = new NextRequest( - `http://localhost/api/connectors?entity_type=artist&entity_id=${mockArtistId}`, - ); + const request = new NextRequest(`http://localhost/api/connectors?entity_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockArtistId); + expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockArtistId, + composioEntityId: mockEntityId, allowedToolkits: ["tiktok"], }); }); - it("should return 403 for artist entity type without access", async () => { + it("should return 403 when entity_id provided but no access", async () => { const mockAccountId = "account-123"; - const mockArtistId = "artist-456"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: mockAccountId, }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); - const request = new NextRequest( - `http://localhost/api/connectors?entity_type=artist&entity_id=${mockArtistId}`, - ); + const request = new NextRequest(`http://localhost/api/connectors?entity_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); expect(result).toBeInstanceOf(NextResponse); @@ -90,29 +85,12 @@ describe("validateGetConnectorsRequest", () => { expect(response.status).toBe(403); }); - it("should return 400 if entity_type is artist but entity_id is missing", async () => { + it("should return 400 for invalid entity_id format", async () => { vi.mocked(validateAccountIdHeaders).mockResolvedValue({ accountId: "account-123", }); - const request = new NextRequest( - "http://localhost/api/connectors?entity_type=artist", - ); - const result = await validateGetConnectorsRequest(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("should reject invalid entity_type", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ - accountId: "account-123", - }); - - const request = new NextRequest( - "http://localhost/api/connectors?entity_type=invalid", - ); + const request = new NextRequest("http://localhost/api/connectors?entity_id=not-a-uuid"); const result = await validateGetConnectorsRequest(request); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index 23af2632..d8c17038 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -13,34 +13,29 @@ export interface AuthorizeResult { * Options for authorizing a connector. */ export interface AuthorizeConnectorOptions { - /** - * Entity type determines how the callback URL is built. - * - "user": Redirects to /settings/connectors - * - "artist": Redirects to /chat with artist_connected param - */ - entityType?: "user" | "artist"; - /** - * For artist entities, the toolkit being connected (for callback URL). - */ - toolkit?: string; /** * Custom auth configs for toolkits that require user-provided OAuth credentials. * e.g., { tiktok: "ac_xxxxx" } */ authConfigs?: Record; /** - * Custom callback URL (overrides default based on entityType). + * Custom callback URL (overrides default). */ customCallbackUrl?: string; + /** + * If true, this is an entity connection (not the user's own). + * Used to determine callback URL destination. + */ + isEntityConnection?: boolean; } /** * Generate an OAuth authorization URL for a connector. * - * Works for both user-level and artist-level connections. - * The entityId can be either a userId or artistId - Composio treats them the same. + * The entityId is an account ID - either the user's own account or + * another account (like an artist) they have access to. * - * @param entityId - The entity ID (userId or artistId) + * @param entityId - The account ID to store the connection under * @param connector - The connector slug (e.g., "googlesheets", "tiktok") * @param options - Authorization options * @returns The redirect URL for OAuth @@ -50,20 +45,22 @@ export async function authorizeConnector( connector: string, options: AuthorizeConnectorOptions = {}, ): Promise { - const { entityType = "user", toolkit, authConfigs, customCallbackUrl } = options; + const { authConfigs, customCallbackUrl, isEntityConnection } = options; const composio = await getComposioClient(); - // Build callback URL based on entity type + // Determine callback URL let callbackUrl: string; if (customCallbackUrl) { callbackUrl = customCallbackUrl; - } else if (entityType === "artist") { + } else if (isEntityConnection) { + // Entity connection: redirect to chat with entity info callbackUrl = getCallbackUrl({ - destination: "artist-connectors", - artistId: entityId, - toolkit: toolkit || connector, + destination: "entity-connectors", + entityId, + toolkit: connector, }); } else { + // User's own connection: redirect to settings callbackUrl = getCallbackUrl({ destination: "connectors" }); } diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index 61f453f8..284086bb 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -8,14 +8,12 @@ import { authorizeConnector } from "./authorizeConnector"; * Handler for POST /api/connectors/authorize. * * Generates an OAuth authorization URL for a specific connector. - * Supports both user and artist connectors via entity_type parameter. + * Supports connecting for the authenticated user or another entity (via entity_id). * * @param request - The incoming request * @returns The redirect URL for OAuth authorization */ -export async function authorizeConnectorHandler( - request: NextRequest, -): Promise { +export async function authorizeConnectorHandler(request: NextRequest): Promise { const headers = getCorsHeaders(); try { @@ -25,13 +23,13 @@ export async function authorizeConnectorHandler( return validated; } - const { composioEntityId, connector, callbackUrl, entityType, authConfigs } = validated; + const { composioEntityId, connector, callbackUrl, authConfigs, isEntityConnection } = validated; // Execute authorization const result = await authorizeConnector(composioEntityId, connector, { customCallbackUrl: callbackUrl, - entityType, authConfigs, + isEntityConnection, }); return NextResponse.json( @@ -46,8 +44,7 @@ export async function authorizeConnectorHandler( ); } catch (error) { console.error("Connector authorize error:", error); - const message = - error instanceof Error ? error.message : "Failed to authorize connector"; + const message = error instanceof Error ? error.message : "Failed to authorize connector"; return NextResponse.json({ error: message }, { status: 500, headers }); } } diff --git a/lib/composio/connectors/disconnectConnector.ts b/lib/composio/connectors/disconnectConnector.ts index f210f6db..046aba23 100644 --- a/lib/composio/connectors/disconnectConnector.ts +++ b/lib/composio/connectors/disconnectConnector.ts @@ -31,9 +31,7 @@ export async function disconnectConnector( // If ownership verification is requested, check before deleting if (verifyOwnershipFor) { const connectors = await getConnectors(verifyOwnershipFor); - const hasConnection = connectors.some( - (c) => c.connectedAccountId === connectedAccountId, - ); + const hasConnection = connectors.some(c => c.connectedAccountId === connectedAccountId); if (!hasConnection) { throw new Error("Connection not found for this entity"); } @@ -54,9 +52,7 @@ export async function disconnectConnector( if (!response.ok) { const errorText = await response.text(); - throw new Error( - `Failed to disconnect (${response.status}): ${errorText}`, - ); + throw new Error(`Failed to disconnect (${response.status}): ${errorText}`); } return { success: true }; diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts index a46f6410..5f88f5ae 100644 --- a/lib/composio/connectors/disconnectConnectorHandler.ts +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -8,14 +8,12 @@ import { disconnectConnector } from "./disconnectConnector"; * Handler for DELETE /api/connectors. * * Disconnects a connected account from Composio. - * Supports both user and artist entities via entity_type body parameter. + * Supports disconnecting for the authenticated user or another entity (via entity_id). * * @param request - The incoming request * @returns Success status */ -export async function disconnectConnectorHandler( - request: NextRequest, -): Promise { +export async function disconnectConnectorHandler(request: NextRequest): Promise { const headers = getCorsHeaders(); try { @@ -25,21 +23,22 @@ export async function disconnectConnectorHandler( return validated; } - const { connectedAccountId, entityType, entityId } = validated; + const { connectedAccountId, entityId } = validated; // Disconnect from Composio - if (entityType === "artist") { + if (entityId) { + // Disconnecting for another entity - verify ownership await disconnectConnector(connectedAccountId, { - verifyOwnershipFor: entityId!, + verifyOwnershipFor: entityId, }); } else { + // User's own connection - already verified in validation await disconnectConnector(connectedAccountId); } return NextResponse.json({ success: true }, { status: 200, headers }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to disconnect connector"; + const message = error instanceof Error ? error.message : "Failed to disconnect connector"; return NextResponse.json({ error: message }, { status: 500, headers }); } } diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index c32178ec..ce71a71b 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -29,10 +29,9 @@ export interface GetConnectorsOptions { /** * Get connectors and their connection status for an entity. * - * Works for both user-level and artist-level connections. - * The entityId can be either a userId or artistId - Composio treats them the same. + * Works for any account ID. Composio uses the entityId to scope connections. * - * @param entityId - The entity ID (userId or artistId) + * @param entityId - The account ID to get connectors for * @param options - Options for filtering and display * @returns List of connectors with connection status */ @@ -52,7 +51,7 @@ export async function getConnectors( const toolkits = await session.toolkits(); // Build connector list - const connectors = toolkits.items.map((toolkit) => ({ + const connectors = toolkits.items.map(toolkit => ({ slug: toolkit.slug, name: displayNames[toolkit.slug] || toolkit.name, isConnected: toolkit.connection?.isActive ?? false, @@ -61,7 +60,7 @@ export async function getConnectors( // If filtering, ensure we return all allowed toolkits (even if not in Composio response) if (allowedToolkits) { - const existingSlugs = new Set(connectors.map((c) => c.slug)); + const existingSlugs = new Set(connectors.map(c => c.slug)); for (const slug of allowedToolkits) { if (!existingSlugs.has(slug)) { connectors.push({ @@ -73,9 +72,7 @@ export async function getConnectors( } } // Filter to only allowed and maintain order - return allowedToolkits.map( - (slug) => connectors.find((c) => c.slug === slug)!, - ); + return allowedToolkits.map(slug => connectors.find(c => c.slug === slug)!); } return connectors; diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index 6c8e4ddf..05649a97 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -18,14 +18,12 @@ const CONNECTOR_DISPLAY_NAMES: Record = { * Handler for GET /api/connectors. * * Lists all available connectors and their connection status. - * Supports both user and artist entities via entity_type query parameter. + * Use entity_id query param to get connectors for a specific entity. * * @param request - The incoming request * @returns List of connectors with connection status */ -export async function getConnectorsHandler( - request: NextRequest, -): Promise { +export async function getConnectorsHandler(request: NextRequest): Promise { const headers = getCorsHeaders(); try { @@ -51,8 +49,7 @@ export async function getConnectorsHandler( { status: 200, headers }, ); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to fetch connectors"; + const message = error instanceof Error ? error.message : "Failed to fetch connectors"; return NextResponse.json({ error: message }, { status: 500, headers }); } } diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts index 015778ec..848b357b 100644 --- a/lib/composio/connectors/index.ts +++ b/lib/composio/connectors/index.ts @@ -1,17 +1,10 @@ -export { - getConnectors, - type ConnectorInfo, - type GetConnectorsOptions, -} from "./getConnectors"; +export { getConnectors, type ConnectorInfo, type GetConnectorsOptions } from "./getConnectors"; export { authorizeConnector, type AuthorizeResult, type AuthorizeConnectorOptions, } from "./authorizeConnector"; -export { - disconnectConnector, - type DisconnectConnectorOptions, -} from "./disconnectConnector"; +export { disconnectConnector, type DisconnectConnectorOptions } from "./disconnectConnector"; export { ALLOWED_ARTIST_CONNECTORS, isAllowedArtistConnector, diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index 3b9b5fee..3a5d2961 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -9,32 +9,18 @@ export const authorizeConnectorBodySchema = z .string({ message: "connector is required" }) .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), callback_url: z.string().url("callback_url must be a valid URL").optional(), - entity_type: z.enum(["user", "artist"]).optional().default("user"), - entity_id: z.string().optional(), + entity_id: z.string().uuid("entity_id must be a valid UUID").optional(), }) .refine( - (data) => { - // entity_id is required when entity_type is "artist" - if (data.entity_type === "artist" && !data.entity_id) { - return false; - } - return true; - }, - { - message: "entity_id is required when entity_type is 'artist'", - path: ["entity_id"], - }, - ) - .refine( - (data) => { - // connector must be in ALLOWED_ARTIST_CONNECTORS when entity_type is "artist" - if (data.entity_type === "artist") { + data => { + // connector must be in ALLOWED_ARTIST_CONNECTORS when entity_id is provided + if (data.entity_id) { return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(data.connector); } return true; }, { - message: `Connector is not allowed for artist connections. Allowed: ${ALLOWED_ARTIST_CONNECTORS.join(", ")}`, + message: `Connector is not allowed for this entity. Allowed: ${ALLOWED_ARTIST_CONNECTORS.join(", ")}`, path: ["connector"], }, ); @@ -44,14 +30,15 @@ export type AuthorizeConnectorBody = z.infer; + isEntityConnection?: boolean; } /** @@ -21,8 +21,8 @@ export interface AuthorizeConnectorParams { * * Handles: * 1. Authentication (x-api-key or Bearer token) - * 2. Body validation (connector, entity_type, entity_id, allowed connector check) - * 3. Access verification (for artist entities) + * 2. Body validation (connector, entity_id, allowed connector check) + * 3. Access verification (when entity_id is provided) * * @param request - The incoming request * @returns NextResponse error or validated params @@ -39,22 +39,19 @@ export async function validateAuthorizeConnectorRequest( } const { accountId } = authResult; - // 2. Validate body (includes allowed connector check for artists) + // 2. Validate body (includes allowed connector check when entity_id is provided) const body = await request.json(); const validated = validateAuthorizeConnectorBody(body); if (validated instanceof NextResponse) { return validated; } - const { connector, callback_url, entity_type, entity_id } = validated; + const { connector, callback_url, entity_id } = validated; - // 3. Verify access and build params - if (entity_type === "artist") { - const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + // 3. If entity_id is provided, verify access and use that entity + if (entity_id) { + const hasAccess = await checkAccountArtistAccess(accountId, entity_id); if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); + return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); } // Build auth configs for custom OAuth @@ -64,18 +61,19 @@ export async function validateAuthorizeConnectorRequest( } return { - composioEntityId: entity_id!, + composioEntityId: entity_id, connector, callbackUrl: callback_url, - entityType: entity_type, authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, + isEntityConnection: true, }; } + // No entity_id: use the authenticated user's account return { composioEntityId: accountId, connector, callbackUrl: callback_url, - entityType: entity_type, + isEntityConnection: false, }; } diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index bd6b1295..3a517e44 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -2,34 +2,21 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -export const disconnectConnectorBodySchema = z - .object({ - connected_account_id: z.string().min(1, "connected_account_id is required"), - entity_type: z.enum(["user", "artist"]).optional().default("user"), - entity_id: z.string().optional(), - }) - .refine( - (data) => { - // entity_id is required when entity_type is "artist" - if (data.entity_type === "artist" && !data.entity_id) { - return false; - } - return true; - }, - { - message: "entity_id is required when entity_type is 'artist'", - path: ["entity_id"], - }, - ); +export const disconnectConnectorBodySchema = z.object({ + connected_account_id: z.string().min(1, "connected_account_id is required"), + entity_id: z.string().uuid("entity_id must be a valid UUID").optional(), +}); export type DisconnectConnectorBody = z.infer; /** * Validates request body for DELETE /api/connectors. * - * Supports both user and artist connectors: - * - User: { connected_account_id: "ca_xxx" } - * - Artist: { connected_account_id: "ca_xxx", entity_type: "artist", entity_id: "artist-uuid" } + * - User disconnect: { connected_account_id: "ca_xxx" } + * - Entity disconnect: { connected_account_id: "ca_xxx", entity_id: "account-uuid" } + * + * When entity_id is provided, verifies the connection belongs to that entity. + * When not provided, verifies the connection belongs to the authenticated user. * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts index 88d7bb90..77ad53d2 100644 --- a/lib/composio/connectors/validateDisconnectConnectorRequest.ts +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -10,9 +10,7 @@ import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; * Validated params for disconnecting a connector. */ export interface DisconnectConnectorParams { - accountId: string; connectedAccountId: string; - entityType: "user" | "artist"; entityId?: string; } @@ -21,8 +19,8 @@ export interface DisconnectConnectorParams { * * Handles: * 1. Authentication (x-api-key or Bearer token) - * 2. Body validation (connected_account_id, entity_type, entity_id) - * 3. Access verification (artist access or connector ownership) + * 2. Body validation (connected_account_id, entity_id) + * 3. Access verification (entity access or connector ownership) * * @param request - The incoming request * @returns NextResponse error or validated params @@ -45,31 +43,28 @@ export async function validateDisconnectConnectorRequest( if (validated instanceof NextResponse) { return validated; } - const { connected_account_id, entity_type, entity_id } = validated; + const { connected_account_id, entity_id } = validated; // 3. Verify access - if (entity_type === "artist") { - const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + if (entity_id) { + // Disconnecting for another entity - verify access to that entity + const hasAccess = await checkAccountArtistAccess(accountId, entity_id); if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); + return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); } } else { + // Disconnecting user's own connection - verify ownership const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); if (!isOwner) { return NextResponse.json( - { error: "Connected account not found or does not belong to this user" }, + { error: "Connected account not found or access denied" }, { status: 403, headers }, ); } } return { - accountId, connectedAccountId: connected_account_id, - entityType: entity_type, entityId: entity_id, }; } diff --git a/lib/composio/connectors/validateGetConnectorsQuery.ts b/lib/composio/connectors/validateGetConnectorsQuery.ts index 5e627174..9bd08974 100644 --- a/lib/composio/connectors/validateGetConnectorsQuery.ts +++ b/lib/composio/connectors/validateGetConnectorsQuery.ts @@ -2,33 +2,17 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -export const getConnectorsQuerySchema = z - .object({ - entity_type: z.enum(["user", "artist"]).optional().default("user"), - entity_id: z.string().optional(), - }) - .refine( - (data) => { - // entity_id is required when entity_type is "artist" - if (data.entity_type === "artist" && !data.entity_id) { - return false; - } - return true; - }, - { - message: "entity_id is required when entity_type is 'artist'", - path: ["entity_id"], - }, - ); +export const getConnectorsQuerySchema = z.object({ + entity_id: z.string().uuid("entity_id must be a valid UUID").optional(), +}); export type GetConnectorsQuery = z.infer; /** * Validates query params for GET /api/connectors. * - * Supports both user and artist connectors: - * - User: No params required (defaults to entity_type=user) - * - Artist: entity_type=artist&entity_id=artist-uuid + * - No params: Returns connectors for the authenticated user + * - entity_id=uuid: Returns connectors for that entity (after access check) * * @param searchParams - The URL search params * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. @@ -37,7 +21,6 @@ export function validateGetConnectorsQuery( searchParams: URLSearchParams, ): NextResponse | GetConnectorsQuery { const queryParams = { - entity_type: searchParams.get("entity_type") ?? undefined, entity_id: searchParams.get("entity_id") ?? undefined, }; diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index 3fcafb3b..3ee6f9c2 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -19,8 +19,8 @@ export interface GetConnectorsParams { * * Handles: * 1. Authentication (x-api-key or Bearer token) - * 2. Query param validation (entity_type, entity_id) - * 3. Access verification (for artist entities) + * 2. Query param validation (entity_id) + * 3. Access verification (when entity_id is provided) * * @param request - The incoming request * @returns NextResponse error or validated params @@ -43,24 +43,22 @@ export async function validateGetConnectorsRequest( if (validated instanceof NextResponse) { return validated; } - const { entity_type, entity_id } = validated; + const { entity_id } = validated; - // 3. Verify access and determine params - if (entity_type === "artist") { - const hasAccess = await checkAccountArtistAccess(accountId, entity_id!); + // 3. If entity_id is provided, verify access and use that entity + if (entity_id) { + const hasAccess = await checkAccountArtistAccess(accountId, entity_id); if (!hasAccess) { - return NextResponse.json( - { error: "Access denied to this artist" }, - { status: 403, headers }, - ); + return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); } return { - composioEntityId: entity_id!, + composioEntityId: entity_id, allowedToolkits: ALLOWED_ARTIST_CONNECTORS, }; } + // No entity_id: use the authenticated user's account return { composioEntityId: accountId, }; diff --git a/lib/composio/connectors/verifyConnectorOwnership.ts b/lib/composio/connectors/verifyConnectorOwnership.ts index dc4f7a67..9e963ac2 100644 --- a/lib/composio/connectors/verifyConnectorOwnership.ts +++ b/lib/composio/connectors/verifyConnectorOwnership.ts @@ -12,12 +12,10 @@ import { getConnectors } from "./getConnectors"; */ export async function verifyConnectorOwnership( accountId: string, - connectedAccountId: string + connectedAccountId: string, ): Promise { const connectors = await getConnectors(accountId); // Check if any of the user's connectors have this connected account ID - return connectors.some( - (connector) => connector.connectedAccountId === connectedAccountId - ); + return connectors.some(connector => connector.connectedAccountId === connectedAccountId); } diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index c1eaaaef..bff700f0 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -4,25 +4,21 @@ import { getFrontendBaseUrl } from "./getFrontendBaseUrl"; * Build OAuth callback URL based on environment and destination. * * Why: Composio redirects users back after OAuth. We need different - * destinations depending on context (chat room vs settings page vs artist connections). + * destinations depending on context (chat for entity connections, + * settings page for user connections). */ -type CallbackDestination = "chat" | "connectors" | "artist-connectors"; +type CallbackDestination = "chat" | "connectors" | "entity-connectors"; -interface CallbackOptions { - destination: CallbackDestination; - roomId?: string; - artistId?: string; - toolkit?: string; -} +type CallbackOptions = + | { destination: "chat"; roomId?: string } + | { destination: "connectors" } + | { destination: "entity-connectors"; entityId: string; toolkit: string }; /** * Build callback URL for OAuth redirects. * - * @param options.destination - Where to redirect: "chat", "connectors", or "artist-connectors" - * @param options.roomId - For chat destination, the room ID to return to - * @param options.artistId - For artist-connectors destination, the artist ID - * @param options.toolkit - For artist-connectors destination, the toolkit slug + * @param options - Callback configuration * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { @@ -32,8 +28,8 @@ export function getCallbackUrl(options: CallbackOptions): string { return `${baseUrl}/settings/connectors?connected=true`; } - if (options.destination === "artist-connectors") { - return `${baseUrl}/chat?artist_connected=${options.artistId}&toolkit=${options.toolkit}`; + if (options.destination === "entity-connectors") { + return `${baseUrl}/chat?artist_connected=${options.entityId}&toolkit=${options.toolkit}`; } // Chat destination diff --git a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts index 07e3425e..63fc3466 100644 --- a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts +++ b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createToolRouterSession } from "../createToolRouterSession"; +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; + vi.mock("../../client", () => ({ getComposioClient: vi.fn(), })); @@ -9,9 +12,6 @@ vi.mock("../../getCallbackUrl", () => ({ getCallbackUrl: vi.fn(), })); -import { getComposioClient } from "../../client"; -import { getCallbackUrl } from "../../getCallbackUrl"; - describe("createToolRouterSession", () => { const mockSession = { tools: vi.fn() }; const mockComposio = { create: vi.fn(() => mockSession) }; @@ -23,10 +23,10 @@ describe("createToolRouterSession", () => { }); it("should create session with enabled toolkits", async () => { - await createToolRouterSession("user-123"); + await createToolRouterSession("account-123"); expect(getComposioClient).toHaveBeenCalled(); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], manageConnections: { callbackUrl: "https://example.com/chat?connected=true", @@ -36,7 +36,7 @@ describe("createToolRouterSession", () => { }); it("should include roomId in callback URL", async () => { - await createToolRouterSession("user-123", "room-456"); + await createToolRouterSession("account-123", "room-456"); expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "chat", @@ -49,9 +49,9 @@ describe("createToolRouterSession", () => { tiktok: "tiktok-account-789", }; - await createToolRouterSession("user-123", undefined, artistConnections); + await createToolRouterSession("account-123", undefined, artistConnections); - expect(mockComposio.create).toHaveBeenCalledWith("user-123", { + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], manageConnections: { callbackUrl: "https://example.com/chat?connected=true", @@ -61,13 +61,13 @@ describe("createToolRouterSession", () => { }); it("should return session object", async () => { - const result = await createToolRouterSession("user-123"); + const result = await createToolRouterSession("account-123"); expect(result).toBe(mockSession); }); it("should handle undefined roomId", async () => { - await createToolRouterSession("user-123", undefined); + await createToolRouterSession("account-123", undefined); expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "chat", diff --git a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts index 98ccab52..b22d44dc 100644 --- a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts +++ b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts @@ -1,23 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; +import { getConnectors } from "../../connectors"; + // Mock dependencies vi.mock("../../connectors", () => ({ getConnectors: vi.fn(), ALLOWED_ARTIST_CONNECTORS: ["tiktok"], })); -import { getConnectors } from "../../connectors"; - describe("getArtistConnectionsFromComposio", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return empty object when no connectors are connected", async () => { - vi.mocked(getConnectors).mockResolvedValue([ - { slug: "tiktok", connectedAccountId: null }, - ]); + vi.mocked(getConnectors).mockResolvedValue([{ slug: "tiktok", connectedAccountId: null }]); const result = await getArtistConnectionsFromComposio("artist-123"); diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts index 22cfa5dc..63c1e283 100644 --- a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getComposioTools } from "../getTools"; +import { createToolRouterSession } from "../createToolRouterSession"; +import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; + // Mock dependencies vi.mock("../createToolRouterSession", () => ({ createToolRouterSession: vi.fn(), @@ -10,9 +13,6 @@ vi.mock("../getArtistConnectionsFromComposio", () => ({ getArtistConnectionsFromComposio: vi.fn(), })); -import { createToolRouterSession } from "../createToolRouterSession"; -import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; - // Mock valid tool structure const createMockTool = () => ({ description: "Test tool", @@ -35,7 +35,7 @@ describe("getComposioTools", () => { it("should return empty object when COMPOSIO_API_KEY is not set", async () => { delete process.env.COMPOSIO_API_KEY; - const result = await getComposioTools("user-123"); + const result = await getComposioTools("account-123"); expect(result).toEqual({}); expect(createToolRouterSession).not.toHaveBeenCalled(); @@ -49,14 +49,10 @@ describe("getComposioTools", () => { }; vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); - await getComposioTools("user-123"); + await getComposioTools("account-123"); expect(getArtistConnectionsFromComposio).not.toHaveBeenCalled(); - expect(createToolRouterSession).toHaveBeenCalledWith( - "user-123", - undefined, - undefined, - ); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", undefined, undefined); }); it("should fetch and pass artist connections when artistId is provided", async () => { @@ -70,14 +66,10 @@ describe("getComposioTools", () => { }; vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); - await getComposioTools("user-123", "artist-456", "room-789"); + await getComposioTools("account-123", "artist-456", "room-789"); expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-456"); - expect(createToolRouterSession).toHaveBeenCalledWith( - "user-123", - "room-789", - mockConnections, - ); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", "room-789", mockConnections); }); it("should pass undefined when artist has no connections", async () => { @@ -90,16 +82,10 @@ describe("getComposioTools", () => { }; vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); - await getComposioTools("user-123", "artist-no-connections"); + await getComposioTools("account-123", "artist-no-connections"); - expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith( - "artist-no-connections", - ); - expect(createToolRouterSession).toHaveBeenCalledWith( - "user-123", - undefined, - undefined, - ); + expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-no-connections"); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", undefined, undefined); }); it("should filter tools to only ALLOWED_TOOLS", async () => { @@ -112,7 +98,7 @@ describe("getComposioTools", () => { }; vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); - const result = await getComposioTools("user-123"); + const result = await getComposioTools("account-123"); expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); expect(result).toHaveProperty("COMPOSIO_SEARCH_TOOLS"); @@ -120,13 +106,11 @@ describe("getComposioTools", () => { }); it("should return empty object when session creation throws", async () => { - vi.mocked(createToolRouterSession).mockRejectedValue( - new Error("Bundler incompatibility"), - ); + vi.mocked(createToolRouterSession).mockRejectedValue(new Error("Bundler incompatibility")); const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const result = await getComposioTools("user-123"); + const result = await getComposioTools("account-123"); expect(result).toEqual({}); expect(consoleSpy).toHaveBeenCalledWith( @@ -146,7 +130,7 @@ describe("getComposioTools", () => { }; vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); - const result = await getComposioTools("user-123"); + const result = await getComposioTools("account-123"); expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); expect(result).not.toHaveProperty("COMPOSIO_SEARCH_TOOLS"); diff --git a/lib/composio/toolRouter/createToolRouterSession.ts b/lib/composio/toolRouter/createToolRouterSession.ts index ac774383..60c6d33d 100644 --- a/lib/composio/toolRouter/createToolRouterSession.ts +++ b/lib/composio/toolRouter/createToolRouterSession.ts @@ -17,7 +17,7 @@ const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"] export async function createToolRouterSession( userId: string, roomId?: string, - artistConnections?: Record + artistConnections?: Record, ) { const composio = await getComposioClient(); diff --git a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts index 1c4f3975..060321c8 100644 --- a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts +++ b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts @@ -10,7 +10,7 @@ import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; * @returns Map of toolkit slug to connected account ID */ export async function getArtistConnectionsFromComposio( - artistId: string + artistId: string, ): Promise> { // Use unified getConnectors with artist filter const connectors = await getConnectors(artistId, { diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index e582f243..db727b18 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -60,7 +60,7 @@ function isValidTool(tool: unknown): tool is Tool { export async function getComposioTools( userId: string, artistId?: string, - roomId?: string + roomId?: string, ): Promise { // Skip Composio if API key is not configured if (!process.env.COMPOSIO_API_KEY) { From f066eb258387173d2686af263ecf7a89ed5d3c8b Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:25:23 -0500 Subject: [PATCH 23/42] fix: remove unrelated changes, keep only composio feature Reverted all non-composio files to main: - Removed createNewRoom.test.ts (unrelated) - Removed evals.yml (unrelated) - Restored all lib/chat, lib/chats, lib/sandbox, lib/tasks, lib/trigger files - Restored package.json, pnpm-lock.yaml, types/database.types.ts PR now contains only: - lib/composio/* (connectors + toolRouter) - app/api/connectors/* - lib/supabase/account_artist_ids/* --- .coderabbit.yaml | 182 + .github/workflows/evals.yml | 47 - CLAUDE.md | 117 +- app/api/chat/generate/route.ts | 1 + app/api/chat/route.ts | 1 + app/api/chats/route.ts | 19 + app/api/sandboxes/route.ts | 64 +- app/api/tasks/runs/route.ts | 34 + lib/chat/__tests__/createNewRoom.test.ts | 140 - lib/chat/__tests__/setupConversation.test.ts | 30 + .../__tests__/setupToolsForRequest.test.ts | 20 +- .../__tests__/validateChatRequest.test.ts | 43 + lib/chat/createNewRoom.ts | 5 +- lib/chat/setupConversation.ts | 4 + lib/chat/setupToolsForRequest.ts | 6 +- lib/chat/validateChatRequest.ts | 2 + lib/chats/__tests__/createChatHandler.test.ts | 68 + lib/chats/__tests__/updateChatHandler.test.ts | 167 + .../__tests__/validateUpdateChatBody.test.ts | 418 + lib/chats/createChatHandler.ts | 14 +- lib/chats/updateChatHandler.ts | 50 + lib/chats/validateCreateChatBody.ts | 1 + lib/chats/validateUpdateChatBody.ts | 104 + lib/sandbox/__tests__/createSandbox.test.ts | 78 +- .../createSandboxPostHandler.test.ts | 305 +- .../__tests__/getSandboxStatus.test.ts | 85 + .../__tests__/getSandboxesHandler.test.ts | 356 + .../updateSnapshotPatchHandler.test.ts | 140 + .../validateGetSandboxesRequest.test.ts | 110 + .../__tests__/validateSandboxBody.test.ts | 44 +- .../validateSnapshotPatchBody.test.ts | 139 + lib/sandbox/createSandbox.ts | 58 +- lib/sandbox/createSandboxPostHandler.ts | 51 +- lib/sandbox/getSandboxStatus.ts | 24 + lib/sandbox/getSandboxesHandler.ts | 64 + lib/sandbox/updateSnapshotPatchHandler.ts | 50 + lib/sandbox/validateGetSandboxesRequest.ts | 67 + lib/sandbox/validateSandboxBody.ts | 6 +- lib/sandbox/validateSnapshotPatchBody.ts | 63 + .../__tests__/insertAccountSandbox.test.ts | 73 + .../__tests__/selectAccountSandboxes.test.ts | 146 + .../account_sandboxes/insertAccountSandbox.ts | 33 + .../selectAccountSandboxes.ts | 58 + .../__tests__/upsertAccountSnapshot.test.ts | 94 + .../selectAccountSnapshots.ts | 25 + .../upsertAccountSnapshot.ts | 47 + lib/supabase/rooms/updateRoom.ts | 30 + lib/tasks/__tests__/getTaskRunHandler.test.ts | 134 + .../__tests__/validateGetTaskRunQuery.test.ts | 124 + lib/tasks/getTaskRunHandler.ts | 54 + lib/tasks/validateGetTaskRunQuery.ts | 50 + lib/trigger/__tests__/retrieveTaskRun.test.ts | 150 + .../triggerRunSandboxCommand.test.ts | 46 + lib/trigger/retrieveTaskRun.ts | 54 + lib/trigger/triggerRunSandboxCommand.ts | 20 + package.json | 2 +- pnpm-lock.yaml | 99 +- types/database.types.ts | 6798 +++++++++-------- 58 files changed, 7445 insertions(+), 3769 deletions(-) create mode 100644 .coderabbit.yaml delete mode 100644 .github/workflows/evals.yml create mode 100644 app/api/tasks/runs/route.ts delete mode 100644 lib/chat/__tests__/createNewRoom.test.ts create mode 100644 lib/chats/__tests__/updateChatHandler.test.ts create mode 100644 lib/chats/__tests__/validateUpdateChatBody.test.ts create mode 100644 lib/chats/updateChatHandler.ts create mode 100644 lib/chats/validateUpdateChatBody.ts create mode 100644 lib/sandbox/__tests__/getSandboxStatus.test.ts create mode 100644 lib/sandbox/__tests__/getSandboxesHandler.test.ts create mode 100644 lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts create mode 100644 lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts create mode 100644 lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts create mode 100644 lib/sandbox/getSandboxStatus.ts create mode 100644 lib/sandbox/getSandboxesHandler.ts create mode 100644 lib/sandbox/updateSnapshotPatchHandler.ts create mode 100644 lib/sandbox/validateGetSandboxesRequest.ts create mode 100644 lib/sandbox/validateSnapshotPatchBody.ts create mode 100644 lib/supabase/account_sandboxes/__tests__/insertAccountSandbox.test.ts create mode 100644 lib/supabase/account_sandboxes/__tests__/selectAccountSandboxes.test.ts create mode 100644 lib/supabase/account_sandboxes/insertAccountSandbox.ts create mode 100644 lib/supabase/account_sandboxes/selectAccountSandboxes.ts create mode 100644 lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts create mode 100644 lib/supabase/account_snapshots/selectAccountSnapshots.ts create mode 100644 lib/supabase/account_snapshots/upsertAccountSnapshot.ts create mode 100644 lib/supabase/rooms/updateRoom.ts create mode 100644 lib/tasks/__tests__/getTaskRunHandler.test.ts create mode 100644 lib/tasks/__tests__/validateGetTaskRunQuery.test.ts create mode 100644 lib/tasks/getTaskRunHandler.ts create mode 100644 lib/tasks/validateGetTaskRunQuery.ts create mode 100644 lib/trigger/__tests__/retrieveTaskRun.test.ts create mode 100644 lib/trigger/__tests__/triggerRunSandboxCommand.test.ts create mode 100644 lib/trigger/retrieveTaskRun.ts create mode 100644 lib/trigger/triggerRunSandboxCommand.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..499aa7e6 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,182 @@ +# CodeRabbit Configuration for SOLID Principles & Clean Code +# Reference: https://docs.coderabbit.ai/reference/configuration + +language: en-US +tone_instructions: "Focus on SOLID principles, clean code practices, and maintainable architecture. Be thorough but constructive in feedback." + +reviews: + profile: chill + high_level_summary: true + poem: true + auto_review: + enabled: true + drafts: false + base_branches: + - main + - test + + # Pre-merge checks for SOLID principles and clean code + pre_merge_checks: + docstrings: + mode: "off" + title: + mode: "off" + description: + mode: "off" + custom_checks: + - mode: "warning" # Change to "error" to block merges on violations + name: "SOLID & Clean Code" + instructions: | + ## Single Responsibility Principle (SRP) + - Each class/function should have only one reason to change + - Functions should do one thing and do it well + - Avoid god classes or functions with multiple responsibilities + - Look for functions longer than 20 lines or classes with >200 lines + + ## Open/Closed Principle (OCP) + - Classes should be open for extension but closed for modification + - Prefer composition over inheritance + - Use interfaces and abstract classes for extensibility + - Avoid modifying existing code when adding new features + + ## DRY Principle (Don't Repeat Yourself) + - Eliminate code duplication by extracting common functionality + - Create reusable functions, components, and utilities + - Use constants for repeated values + - Consolidate similar logic into shared modules + - Avoid copy-paste programming + - Extract repeated patterns into abstractions + - Use configuration objects instead of hardcoded values + + ## KISS Principle (Keep It Simple, Stupid) + - Prefer simple solutions over complex ones + - Avoid over-engineering and premature optimization + - Use clear, readable code over clever code + - Choose straightforward algorithms over complex ones + - Avoid unnecessary abstractions and layers + - Write code that's easy to understand and maintain + - Prefer explicit over implicit behavior + + ## Clean Code Practices + - Use descriptive names for variables, functions, and classes + - Keep functions small and focused + - Avoid deep nesting (max 3 levels) + - Use meaningful comments, not obvious ones + - Follow consistent naming conventions + - Handle errors gracefully + - Write self-documenting code + + # Path-specific instructions for different file types + path_instructions: + # API Routes + - path: "app/api/**/*.ts" + instructions: | + For API routes, ensure: + - Single responsibility per route handler + - Proper error handling and validation + - Use dependency injection for services + - Follow RESTful principles + - Validate input parameters using Zod schemas + - Return consistent response formats + - Use validateAuthContext() for authentication (supports both API keys and Bearer tokens) + - DRY: Extract common validation and error handling logic + - KISS: Use simple, straightforward request/response patterns + + # Domain Logic + - path: "lib/**/*.ts" + instructions: | + For domain functions, ensure: + - Pure functions when possible + - Single responsibility per function + - Proper error handling + - Use TypeScript for type safety + - Avoid side effects + - Keep functions under 50 lines + - DRY: Consolidate similar logic into shared utilities + - KISS: Prefer simple, readable implementations over clever optimizations + + # Supabase Database Operations + - path: "lib/supabase/**/*.ts" + instructions: | + For Supabase operations, ensure: + - Follow naming convention: select*, insert*, update*, delete*, get* (for complex queries) + - One exported function per file + - Import serverClient only within lib/supabase/ + - Return typed results using Tables<"table_name"> + - Handle errors gracefully + - Use proper query building patterns + + # MCP Tools + - path: "lib/mcp/tools/**/*.ts" + instructions: | + For MCP tools, ensure: + - Use Zod schemas for input validation + - Use resolveAccountId() for authentication + - Use getToolResultSuccess/getToolResultError for responses + - Share domain logic with API routes (DRY) + - Follow registration pattern in register*Tool.ts files + + # Validation Functions + - path: "lib/**/validate*.ts" + instructions: | + For validation functions, ensure: + - Use Zod for schema validation + - Return NextResponse on error or validated data on success + - Export inferred types for validated data + - Follow naming: validateBody.ts or validateQuery.ts + + # Utils folder restrictions + - path: "utils/**" + instructions: | + NEVER use the utils folder! + - This folder is deprecated and should not be used + - Instead, organize utilities by domain: lib/[domain]/[functionName].ts + - Example: lib/auth/validateToken.ts instead of utils/auth.ts + - This provides better organization and discoverability + + # Generic lib/utils restrictions + - path: "lib/utils/**" + instructions: | + NEVER use a generic utils folder in lib! + - Avoid lib/utils/ as it creates a catch-all dumping ground + - Instead, organize by domain: lib/[domain]/[functionName].ts + - Example: lib/validation/emailValidator.ts instead of lib/utils/validation.ts + - Example: lib/formatting/dateFormatter.ts instead of lib/utils/formatters.ts + - This makes code more discoverable and maintainable + + # File patterns to include/exclude + path_filters: + - "lib/**" + - "app/**" + - "!**/*.test.*" + - "!**/*.spec.*" + - "!**/node_modules/**" + - "!**/dist/**" + - "!**/build/**" + - "!**/__tests__/**" + - "!**/evals/**" + + # Additional checks + commit_status: true + review_status: true + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: true + suggested_reviewers: true + +# Knowledge base configuration +knowledge_base: + learnings: + scope: auto + issues: + scope: auto + pull_requests: + scope: auto + +# Global settings +early_access: false +enable_free_tier: true diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml deleted file mode 100644 index c31fd81d..00000000 --- a/.github/workflows/evals.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Evals - -on: - pull_request: - branches: [main, test] - -permissions: - contents: read - pull-requests: write - -jobs: - evals: - runs-on: ubuntu-latest - timeout-minutes: 11 - env: - PRIVY_PROJECT_SECRET: ${{ secrets.PRIVY_PROJECT_SECRET }} - # Note: COMPOSIO_API_KEY intentionally omitted - @composio packages - # are incompatible with Braintrust's bundler (createRequire issue) - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Install dependencies - run: pnpm install - - - name: Run Evals - uses: braintrustdata/eval-action@v1 - with: - api_key: ${{ secrets.BRAINTRUST_API_KEY }} - runtime: node - paths: "evals/" - package_manager: pnpm diff --git a/CLAUDE.md b/CLAUDE.md index a9d849a0..a64148a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 4. Before pushing, verify the current branch is not `main` or `test` 5. **Open PRs against the `test` branch**, not `main` 6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base test` +7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. ### Starting a New Task @@ -140,80 +141,76 @@ export async function selectTableName({ - **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. - **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities. - **KISS (Keep It Simple)**: Prefer simple solutions over clever ones. -- **TDD (Test-Driven Development)**: API changes should include unit tests. - All API routes should have JSDoc comments - Run `pnpm lint` before committing -## API Route Patterns +## Test-Driven Development (TDD) -Follow the `/api/pulses` pattern for all API routes: +**CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.** -### Thin Route Files -Route files should be minimal - just call handler functions: +### TDD Workflow -```typescript -// app/api/example/route.ts -export async function GET(request: NextRequest) { - return getExampleHandler(request); -} +1. **Write failing tests first** - Create tests in `lib/[domain]/__tests__/[filename].test.ts` that describe the expected behavior +2. **Run tests to verify they fail** - `pnpm test path/to/test.ts` +3. **Implement the code** - Write the minimum code needed to make tests pass +4. **Run tests to verify they pass** - All tests should be green +5. **Refactor if needed** - Clean up while keeping tests green -export async function POST(request: NextRequest) { - return createExampleHandler(request); -} -``` +### Test File Location -### Handler Functions -Handlers live in `lib/` and orchestrate validation + business logic: - -```typescript -// lib/example/getExampleHandler.ts -export async function getExampleHandler(request: NextRequest) { - const validated = await validateGetExampleRequest(request); - if (validated instanceof NextResponse) return validated; - - // Business logic here - const result = await getExample(validated.params); - return NextResponse.json({ success: true, data: result }); -} +Tests live alongside the code they test: +``` +lib/ +├── chats/ +│ ├── __tests__/ +│ │ └── updateChatHandler.test.ts +│ ├── updateChatHandler.ts +│ └── validateUpdateChatBody.ts ``` -### Combined Request Validators -Create `validateXxxRequest` functions that handle auth + input validation + access checks: +### Test Pattern ```typescript -// lib/example/validateGetExampleRequest.ts -export async function validateGetExampleRequest(request: NextRequest) { - // 1. Auth validation - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) return authResult; - - // 2. Input validation (Zod) - const validated = validateGetExampleQuery(searchParams); - if (validated instanceof NextResponse) return validated; - - // 3. Access checks (if needed) - const hasAccess = await checkAccess(authResult.accountId, validated.resourceId); - if (!hasAccess) return NextResponse.json({ error: "Access denied" }, { status: 403 }); - - return { ...validated, accountId: authResult.accountId }; -} +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +// Mock dependencies +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +describe("functionName", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("does something when condition is met", async () => { + // Arrange + vi.mocked(dependency).mockResolvedValue(mockData); + + // Act + const result = await functionName(input); + + // Assert + expect(result.status).toBe(200); + }); + }); + + describe("error cases", () => { + it("returns 400 when validation fails", async () => { + // Test error handling + }); + }); +}); ``` -### DRY: Avoid Duplicate Libraries -When adding features for different entity types (e.g., user vs artist): -- **DON'T** create separate files like `getUserThing.ts` and `getArtistThing.ts` -- **DO** create one unified function with options: `getThing(entityId, { entityType: "artist" })` -- **DON'T** create separate API routes like `/api/user-things` and `/api/artist-things` -- **DO** create one unified endpoint with parameters: `/api/things?entity_type=artist&entity_id=xxx` - -### File Naming -- Name files after the **primary function** they export, not constants -- Example: `isAllowedConnector.ts` not `ALLOWED_CONNECTORS.ts` - -### Testing -- All API changes should include unit tests -- Test validators with mocked dependencies -- Cover: auth failures, validation errors, access denied, success cases +### When to Write Tests + +- **New API endpoints**: Write tests for all success and error paths +- **New handlers**: Test business logic with mocked dependencies +- **Bug fixes**: Write a failing test that reproduces the bug, then fix it +- **Validation functions**: Test all valid and invalid input combinations ## Authentication diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts index c1acb2f1..0176d0ee 100644 --- a/app/api/chat/generate/route.ts +++ b/app/api/chat/generate/route.ts @@ -27,6 +27,7 @@ export async function OPTIONS() { * - messages: Array of chat messages (mutually exclusive with prompt) * - prompt: String prompt (mutually exclusive with messages) * - roomId: Optional UUID of the chat room + * - topic: Optional topic for new chat room (ignored if room already exists) * - artistId: Optional UUID of the artist account * - model: Optional model ID override * - excludeTools: Optional array of tool names to exclude diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 644d7a5d..973166f0 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -27,6 +27,7 @@ export async function OPTIONS() { * - messages: Array of chat messages (mutually exclusive with prompt) * - prompt: String prompt (mutually exclusive with messages) * - roomId: Optional UUID of the chat room + * - topic: Optional topic for new chat room (ignored if room already exists) * - artistId: Optional UUID of the artist account * - model: Optional model ID override * - excludeTools: Optional array of tool names to exclude diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index ca58f6cf..dbfe7cfb 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createChatHandler } from "@/lib/chats/createChatHandler"; import { getChatsHandler } from "@/lib/chats/getChatsHandler"; +import { updateChatHandler } from "@/lib/chats/updateChatHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -52,3 +53,21 @@ export async function GET(request: NextRequest): Promise { export async function POST(request: NextRequest): Promise { return createChatHandler(request); } + +/** + * PATCH /api/chats + * + * Update a chat room's topic (display name). + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Body parameters: + * - chatId (required): UUID of the chat room to update + * - topic (required): New display name for the chat (3-50 characters) + * + * @param request - The request object + * @returns A NextResponse with the updated chat or an error + */ +export async function PATCH(request: NextRequest): Promise { + return updateChatHandler(request); +} diff --git a/app/api/sandboxes/route.ts b/app/api/sandboxes/route.ts index 9db8f8a9..ba07ce7f 100644 --- a/app/api/sandboxes/route.ts +++ b/app/api/sandboxes/route.ts @@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createSandboxPostHandler } from "@/lib/sandbox/createSandboxPostHandler"; +import { getSandboxesHandler } from "@/lib/sandbox/getSandboxesHandler"; +import { updateSnapshotPatchHandler } from "@/lib/sandbox/updateSnapshotPatchHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -18,7 +20,7 @@ export async function OPTIONS() { /** * POST /api/sandboxes * - * Creates a new ephemeral sandbox environment. + * Creates a new ephemeral sandbox environment. Optionally executes a command. * Sandboxes are isolated Linux microVMs that can be used to evaluate * account-generated code, run AI agent output safely, or execute reproducible tasks. * The sandbox will automatically stop after the timeout period. @@ -26,11 +28,14 @@ export async function OPTIONS() { * Authentication: x-api-key header or Authorization Bearer token required. * * Request body: - * - prompt: string (required, min length 1) - The prompt to send to Claude Code + * - command: string (optional) - The command to execute in the sandbox. If omitted, sandbox is created without running any command. + * - args: string[] (optional) - Arguments to pass to the command + * - cwd: string (optional) - Working directory for command execution * * Response (200): * - status: "success" - * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt }] + * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt, runId? }] + * - runId is only included when a command was provided * * Error (400/401): * - status: "error" @@ -42,3 +47,56 @@ export async function OPTIONS() { export async function POST(request: NextRequest): Promise { return createSandboxPostHandler(request); } + +/** + * GET /api/sandboxes + * + * Lists all sandboxes associated with the authenticated account and their current statuses. + * Returns sandbox details including lifecycle state, timeout remaining, and creation timestamp. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Query parameters: + * - sandbox_id: (optional) Filter by a specific sandbox ID. Must be a sandbox + * that your account or organization is an admin of. + * + * Response (200): + * - status: "success" + * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt }] + * + * Error (401): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with sandbox statuses + */ +export async function GET(request: NextRequest): Promise { + return getSandboxesHandler(request); +} + +/** + * PATCH /api/sandboxes + * + * Updates the snapshot ID for an account. This snapshot will be used + * as the base environment when creating new sandboxes. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Request body: + * - snapshotId: string (required) - The snapshot ID to set for the account + * + * Response (200): + * - success: boolean + * - snapshotId: string - The snapshot ID that was set + * + * Error (400/401): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with the updated snapshot ID or error + */ +export async function PATCH(request: NextRequest): Promise { + return updateSnapshotPatchHandler(request); +} diff --git a/app/api/tasks/runs/route.ts b/app/api/tasks/runs/route.ts new file mode 100644 index 00000000..b9c87602 --- /dev/null +++ b/app/api/tasks/runs/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getTaskRunHandler } from "@/lib/tasks/getTaskRunHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/tasks/runs + * + * Retrieves the status of a Trigger.dev task run. + * Returns one of three possible statuses: + * - pending: Task is still running + * - complete: Task completed successfully with data + * - failed: Task failed with error message + * + * Query parameters: + * - runId (required): The unique identifier of the task run + * + * @param request - The request object containing query parameters. + * @returns A NextResponse with task run status. + */ +export async function GET(request: NextRequest) { + return getTaskRunHandler(request); +} diff --git a/lib/chat/__tests__/createNewRoom.test.ts b/lib/chat/__tests__/createNewRoom.test.ts deleted file mode 100644 index 241a40c4..00000000 --- a/lib/chat/__tests__/createNewRoom.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createNewRoom } from "../createNewRoom"; - -vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ - upsertRoom: vi.fn(), -})); - -vi.mock("@/lib/chat/generateChatTitle", () => ({ - generateChatTitle: vi.fn(), -})); - -vi.mock("@/lib/telegram/sendNewConversationNotification", () => ({ - sendNewConversationNotification: vi.fn(), -})); - -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: vi.fn(), -})); - -import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; -import { generateChatTitle } from "@/lib/chat/generateChatTitle"; -import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -describe("createNewRoom", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateChatTitle).mockResolvedValue("Generated Title"); - vi.mocked(selectAccountEmails).mockResolvedValue([]); - vi.mocked(upsertRoom).mockResolvedValue(undefined); - vi.mocked(sendNewConversationNotification).mockResolvedValue(undefined); - }); - - it("should create room with generated title", async () => { - const lastMessage = { - id: "msg-1", - role: "user" as const, - parts: [{ type: "text" as const, text: "Hello, how are you?" }], - }; - - await createNewRoom({ - accountId: "account-123", - roomId: "room-456", - lastMessage, - }); - - expect(generateChatTitle).toHaveBeenCalledWith("Hello, how are you?"); - expect(upsertRoom).toHaveBeenCalledWith({ - account_id: "account-123", - topic: "Generated Title", - artist_id: undefined, - id: "room-456", - }); - }); - - it("should include artist_id when provided", async () => { - const lastMessage = { - id: "msg-1", - role: "user" as const, - parts: [{ type: "text" as const, text: "Hello" }], - }; - - await createNewRoom({ - accountId: "account-123", - roomId: "room-456", - artistId: "artist-789", - lastMessage, - }); - - expect(upsertRoom).toHaveBeenCalledWith( - expect.objectContaining({ - artist_id: "artist-789", - }), - ); - }); - - it("should send notification with account email when available", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([ - { email: "user@example.com" }, - ]); - - const lastMessage = { - id: "msg-1", - role: "user" as const, - parts: [{ type: "text" as const, text: "Hello" }], - }; - - await createNewRoom({ - accountId: "account-123", - roomId: "room-456", - lastMessage, - }); - - expect(sendNewConversationNotification).toHaveBeenCalledWith({ - accountId: "account-123", - email: "user@example.com", - conversationId: "room-456", - topic: "Generated Title", - firstMessage: "Hello", - }); - }); - - it("should send notification with empty email when no account email", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([]); - - const lastMessage = { - id: "msg-1", - role: "user" as const, - parts: [{ type: "text" as const, text: "Hello" }], - }; - - await createNewRoom({ - accountId: "account-123", - roomId: "room-456", - lastMessage, - }); - - expect(sendNewConversationNotification).toHaveBeenCalledWith( - expect.objectContaining({ - email: "", - }), - ); - }); - - it("should handle message with empty text", async () => { - const lastMessage = { - id: "msg-1", - role: "user" as const, - parts: [], - }; - - await createNewRoom({ - accountId: "account-123", - roomId: "room-456", - lastMessage, - }); - - expect(generateChatTitle).toHaveBeenCalledWith(""); - }); -}); diff --git a/lib/chat/__tests__/setupConversation.test.ts b/lib/chat/__tests__/setupConversation.test.ts index 04dab1fc..9f395d3d 100644 --- a/lib/chat/__tests__/setupConversation.test.ts +++ b/lib/chat/__tests__/setupConversation.test.ts @@ -128,6 +128,36 @@ describe("setupConversation", () => { }), ); }); + + it("passes topic to createNewRoom when provided", async () => { + const promptMessage = createUIMessage("Hello"); + + await setupConversation({ + accountId: "account-123", + promptMessage, + topic: "Pulse Feb 2", + }); + + expect(mockCreateNewRoom).toHaveBeenCalledWith( + expect.objectContaining({ + topic: "Pulse Feb 2", + }), + ); + }); + + it("does NOT pass topic to createNewRoom when room already exists", async () => { + mockSelectRoom.mockResolvedValue({ id: "existing-room-id" } as never); + const promptMessage = createUIMessage("Hello"); + + await setupConversation({ + accountId: "account-123", + roomId: "existing-room-id", + topic: "Should be ignored", + promptMessage, + }); + + expect(mockCreateNewRoom).not.toHaveBeenCalled(); + }); }); describe("message persistence", () => { diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index a5bbf053..15522f62 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -81,7 +81,7 @@ describe("setupToolsForRequest", () => { }); describe("Composio tools integration", () => { - it("calls getComposioTools with accountId, artistId, and roomId", async () => { + it("calls getComposioTools with accountId and roomId", async () => { const body: ChatRequestBody = { accountId: "account-123", orgId: null, @@ -92,23 +92,7 @@ describe("setupToolsForRequest", () => { await setupToolsForRequest(body); - // getComposioTools signature: (userId, artistId?, roomId?) - expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", undefined, "room-456"); - }); - - it("calls getComposioTools with artistId when provided", async () => { - const body: ChatRequestBody = { - accountId: "account-123", - orgId: null, - authToken: "test-token-123", - roomId: "room-456", - artistId: "artist-789", - messages: [{ id: "1", role: "user", content: "Post to TikTok" }], - }; - - await setupToolsForRequest(body); - - expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "artist-789", "room-456"); + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "room-456"); }); it("includes Composio tools in result", async () => { diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index 59a9fe2b..a5876eca 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -442,6 +442,20 @@ describe("validateChatRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect((result as any).excludeTools).toEqual(["tool1", "tool2"]); }); + + it("passes through topic", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { prompt: "Hello", topic: "Pulse Feb 2" }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatRequest(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).topic).toBe("Pulse Feb 2"); + }); }); describe("chatRequestSchema", () => { @@ -465,6 +479,14 @@ describe("validateChatRequest", () => { }); expect(result.success).toBe(false); }); + + it("schema accepts optional topic string", () => { + const result = chatRequestSchema.safeParse({ + prompt: "test", + topic: "Pulse Feb 2", + }); + expect(result.success).toBe(true); + }); }); describe("organizationId override", () => { @@ -650,6 +672,27 @@ describe("validateChatRequest", () => { ); }); + it("passes topic to setupConversation when provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "generated-uuid-topic", + memoryId: "memory-id", + }); + + const request = createMockRequest( + { prompt: "Hello", topic: "Pulse Feb 2" }, + { "x-api-key": "test-key" }, + ); + + await validateChatRequest(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + topic: "Pulse Feb 2", + }), + ); + }); + it("returns provided roomId when roomId is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); mockSetupConversation.mockResolvedValue({ diff --git a/lib/chat/createNewRoom.ts b/lib/chat/createNewRoom.ts index ee79a47e..72457d2f 100644 --- a/lib/chat/createNewRoom.ts +++ b/lib/chat/createNewRoom.ts @@ -7,6 +7,7 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmai interface CreateNewRoomParams { accountId: string; roomId: string; + topic?: string; artistId?: string; lastMessage: UIMessage; } @@ -17,6 +18,7 @@ interface CreateNewRoomParams { * @param params - The parameters for creating a new room * @param params.accountId - The account ID * @param params.roomId - The room ID + * @param params.topic - Optional topic for the room. If not provided, one is generated from the message. * @param params.artistId - Optional artist ID * @param params.lastMessage - The last message from the conversation * @returns void @@ -24,11 +26,12 @@ interface CreateNewRoomParams { export async function createNewRoom({ accountId, roomId, + topic, artistId, lastMessage, }: CreateNewRoomParams): Promise { const latestMessageText = lastMessage.parts.find(part => part.type === "text")?.text || ""; - const conversationName = await generateChatTitle(latestMessageText); + const conversationName = topic || (await generateChatTitle(latestMessageText)); let email = ""; const accountEmails = await selectAccountEmails({ accountIds: accountId }); diff --git a/lib/chat/setupConversation.ts b/lib/chat/setupConversation.ts index 1894bb37..8b1d6afd 100644 --- a/lib/chat/setupConversation.ts +++ b/lib/chat/setupConversation.ts @@ -8,6 +8,7 @@ import filterMessageContentForMemories from "@/lib/messages/filterMessageContent interface SetupConversationParams { accountId: string; roomId?: string; + topic?: string; promptMessage: UIMessage; artistId?: string; memoryId?: string; @@ -28,6 +29,7 @@ interface SetupConversationResult { * @param root0 - The setup conversation parameters * @param root0.accountId - The account ID for the conversation * @param root0.roomId - Optional existing room ID. If not provided, a new room is created. + * @param root0.topic - Optional topic for the new room. Ignored if room already exists. * @param root0.promptMessage - The user's message in UIMessage format * @param root0.artistId - Optional artist ID for the room * @param root0.memoryId - Optional memory ID. If not provided, a UUID is generated. @@ -38,6 +40,7 @@ interface SetupConversationResult { export async function setupConversation({ accountId, roomId, + topic, promptMessage, artistId, memoryId, @@ -60,6 +63,7 @@ export async function setupConversation({ await createNewRoom({ accountId, roomId: finalRoomId, + topic, artistId, lastMessage: promptMessage, }); diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index ba586e19..77b1747d 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -8,18 +8,18 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; * Sets up and filters tools for a chat request. * Aggregates tools from: * - MCP server (via HTTP transport to /api/mcp for proper auth) - * - Composio Tool Router (Google Sheets, Google Drive, Google Docs, TikTok) + * - Composio Tool Router (Google Sheets, Google Drive, Google Docs) * * @param body - The chat request body * @returns Filtered tool set ready for use */ export async function setupToolsForRequest(body: ChatRequestBody): Promise { - const { accountId, artistId, roomId, excludeTools, authToken } = body; + const { accountId, roomId, excludeTools, authToken } = body; // Fetch MCP tools and Composio tools in parallel - they're independent const [mcpTools, composioTools] = await Promise.all([ authToken ? getMcpTools(authToken) : Promise.resolve({}), - getComposioTools(accountId, artistId, roomId), + getComposioTools(accountId, roomId), ]); // Merge all tools diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 114656c3..92219e6d 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -19,6 +19,7 @@ export const chatRequestSchema = z messages: z.array(z.any()).default([]), // Core routing / context fields roomId: z.string().optional(), + topic: z.string().optional(), accountId: z.string().optional(), artistId: z.string().optional(), organizationId: z.string().optional(), @@ -188,6 +189,7 @@ export async function validateChatRequest( const { roomId: finalRoomId } = await setupConversation({ accountId, roomId: validatedBody.roomId, + topic: validatedBody.topic, promptMessage: lastMessage, artistId: validatedBody.artistId, memoryId: lastMessage.id, diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index fc45a638..ff8ca02c 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -174,6 +174,74 @@ describe("createChatHandler", () => { }); }); + describe("with topic parameter", () => { + it("uses provided topic directly without generating", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + const topic = "My Custom Topic"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + topic, + }); + vi.mocked(upsertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(generateChatTitle).not.toHaveBeenCalled(); + expect(upsertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic, + }); + }); + + it("uses provided topic even when firstMessage is also provided", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + const topic = "My Custom Topic"; + const firstMessage = "What marketing strategies should I use?"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + topic, + firstMessage, + }); + vi.mocked(upsertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(generateChatTitle).not.toHaveBeenCalled(); + expect(upsertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic, + }); + }); + }); + describe("with firstMessage (title generation)", () => { it("generates a title from firstMessage when provided", async () => { const apiKeyAccountId = "api-key-account-123"; diff --git a/lib/chats/__tests__/updateChatHandler.test.ts b/lib/chats/__tests__/updateChatHandler.test.ts new file mode 100644 index 00000000..cbb33603 --- /dev/null +++ b/lib/chats/__tests__/updateChatHandler.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { updateChatHandler } from "../updateChatHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/chats/validateUpdateChatBody", () => ({ + validateUpdateChatBody: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/updateRoom", () => ({ + updateRoom: vi.fn(), +})); + +import { validateUpdateChatBody } from "@/lib/chats/validateUpdateChatBody"; +import { updateRoom } from "@/lib/supabase/rooms/updateRoom"; + +describe("updateChatHandler", () => { + const mockRequest = () => { + return new NextRequest("http://localhost/api/chats", { + method: "PATCH", + headers: { "x-api-key": "test-key", "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful update", () => { + it("updates chat topic and returns success response", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174001"; + const accountId = "123e4567-e89b-12d3-a456-426614174000"; + const newTopic = "My Updated Chat"; + + vi.mocked(validateUpdateChatBody).mockResolvedValue({ + chatId, + topic: newTopic, + }); + + vi.mocked(updateRoom).mockResolvedValue({ + id: chatId, + account_id: accountId, + artist_id: null, + topic: newTopic, + updated_at: "2024-01-02T00:00:00Z", + }); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ + status: "success", + chat: { + id: chatId, + account_id: accountId, + topic: newTopic, + updated_at: "2024-01-02T00:00:00Z", + artist_id: null, + }, + }); + + expect(updateRoom).toHaveBeenCalledWith(chatId, { topic: newTopic }); + }); + }); + + describe("validation errors", () => { + it("returns 400 from validation when body is invalid", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue( + NextResponse.json( + { status: "error", error: "chatId must be a valid UUID" }, + { status: 400 }, + ), + ); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(400); + expect(updateRoom).not.toHaveBeenCalled(); + }); + + it("returns 401 from validation when auth fails", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ), + ); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(401); + expect(updateRoom).not.toHaveBeenCalled(); + }); + + it("returns 404 from validation when chat not found", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Chat room not found" }, + { status: 404 }, + ), + ); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(404); + expect(updateRoom).not.toHaveBeenCalled(); + }); + + it("returns 403 from validation when access denied", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403 }, + ), + ); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(403); + expect(updateRoom).not.toHaveBeenCalled(); + }); + }); + + describe("update errors", () => { + it("returns 500 when updateRoom fails", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue({ + chatId: "123e4567-e89b-12d3-a456-426614174001", + topic: "New Topic", + }); + + vi.mocked(updateRoom).mockResolvedValue(null); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toContain("Failed to update"); + }); + + it("returns 500 when updateRoom throws", async () => { + vi.mocked(validateUpdateChatBody).mockResolvedValue({ + chatId: "123e4567-e89b-12d3-a456-426614174001", + topic: "New Topic", + }); + + vi.mocked(updateRoom).mockRejectedValue(new Error("Database error")); + + const request = mockRequest(); + const response = await updateChatHandler(request); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Database error"); + }); + }); +}); diff --git a/lib/chats/__tests__/validateUpdateChatBody.test.ts b/lib/chats/__tests__/validateUpdateChatBody.test.ts new file mode 100644 index 00000000..d3328d98 --- /dev/null +++ b/lib/chats/__tests__/validateUpdateChatBody.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateUpdateChatBody } from "../validateUpdateChatBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/chats/buildGetChatsParams", () => ({ + buildGetChatsParams: vi.fn(), +})); + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; + +describe("validateUpdateChatBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createRequest = (body: object) => { + return new NextRequest("http://localhost/api/chats", { + method: "PATCH", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify(body), + }); + }; + + describe("successful validation", () => { + it("returns validated data when user owns the chat", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const accountId = "123e4567-e89b-12d3-a456-426614174001"; + const topic = "Valid Topic"; + const room = { + id: chatId, + account_id: accountId, + artist_id: null, + topic: "Old Topic", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const request = createRequest({ chatId, topic }); + const result = await validateUpdateChatBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ chatId, topic }); + }); + + it("accepts topic at minimum length (3 chars)", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const accountId = "123e4567-e89b-12d3-a456-426614174001"; + const topic = "abc"; + const room = { + id: chatId, + account_id: accountId, + artist_id: null, + topic: "Old", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const request = createRequest({ chatId, topic }); + const result = await validateUpdateChatBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ chatId, topic }); + }); + + it("accepts topic at maximum length (50 chars)", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const accountId = "123e4567-e89b-12d3-a456-426614174001"; + const topic = "a".repeat(50); + const room = { + id: chatId, + account_id: accountId, + artist_id: null, + topic: "Old", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const request = createRequest({ chatId, topic }); + const result = await validateUpdateChatBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ chatId, topic }); + }); + + it("allows org key to access member's chat", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const memberAccountId = "123e4567-e89b-12d3-a456-426614174001"; + const orgId = "123e4567-e89b-12d3-a456-426614174002"; + const topic = "Valid Topic"; + const room = { + id: chatId, + account_id: memberAccountId, + artist_id: null, + topic: "Old", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: orgId, + orgId, + authToken: "org-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [memberAccountId, "other-member"] }, + error: null, + }); + + const request = createRequest({ chatId, topic }); + const result = await validateUpdateChatBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ chatId, topic }); + }); + + it("allows admin access when account_ids is undefined", async () => { + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const accountId = "123e4567-e89b-12d3-a456-426614174001"; + const topic = "Valid Topic"; + const room = { + id: chatId, + account_id: "any-account", + artist_id: null, + topic: "Old", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: "admin-org", + authToken: "admin-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: {}, // No account_ids = admin access + error: null, + }); + + const request = createRequest({ chatId, topic }); + const result = await validateUpdateChatBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ chatId, topic }); + }); + }); + + describe("body validation errors", () => { + it("returns 400 when chatId is missing", async () => { + const request = createRequest({ topic: "Valid Topic" }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.status).toBe("error"); + }); + + it("returns 400 when chatId is not a valid UUID", async () => { + const request = createRequest({ chatId: "not-a-uuid", topic: "Valid Topic" }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("UUID"); + }); + + it("returns 400 when topic is missing", async () => { + const request = createRequest({ chatId: "123e4567-e89b-12d3-a456-426614174000" }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("returns 400 when topic is too short", async () => { + const request = createRequest({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + topic: "ab", + }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("3 and 50"); + }); + + it("returns 400 when topic is too long", async () => { + const request = createRequest({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + topic: "a".repeat(51), + }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("3 and 50"); + }); + }); + + describe("JSON parsing errors", () => { + it("handles invalid JSON gracefully", async () => { + const request = new NextRequest("http://localhost/api/chats", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: "not valid json", + }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("handles empty body gracefully", async () => { + const request = new NextRequest("http://localhost/api/chats", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + }); + + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + }); + + describe("authentication errors", () => { + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ), + ); + + const request = createRequest({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + topic: "Valid Topic", + }); + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + }); + + describe("room not found errors", () => { + it("returns 404 when chat does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "123e4567-e89b-12d3-a456-426614174001", + orgId: null, + authToken: "test-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(null); + + const request = createRequest({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + topic: "Valid Topic", + }); + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toContain("not found"); + }); + }); + + describe("access denied errors", () => { + it("returns 403 when user tries to update another user's chat", async () => { + const userAccountId = "123e4567-e89b-12d3-a456-426614174001"; + const otherAccountId = "123e4567-e89b-12d3-a456-426614174002"; + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const room = { + id: chatId, + account_id: otherAccountId, + artist_id: null, + topic: "Old Topic", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: userAccountId, + orgId: null, + authToken: "test-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [userAccountId] }, + error: null, + }); + + const request = createRequest({ + chatId, + topic: "Valid Topic", + }); + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toContain("Access denied"); + }); + + it("returns 403 when org key cannot access non-member's chat", async () => { + const orgId = "123e4567-e89b-12d3-a456-426614174001"; + const nonMemberAccountId = "123e4567-e89b-12d3-a456-426614174002"; + const chatId = "123e4567-e89b-12d3-a456-426614174000"; + const room = { + id: chatId, + account_id: nonMemberAccountId, + artist_id: null, + topic: "Old Topic", + updated_at: "2024-01-01T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: orgId, + orgId, + authToken: "org-key", + }); + + vi.mocked(selectRoom).mockResolvedValue(room); + + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: ["member-1", "member-2"] }, // non-member not in list + error: null, + }); + + const request = createRequest({ + chatId, + topic: "Valid Topic", + }); + const result = await validateUpdateChatBody(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toContain("Access denied"); + }); + }); +}); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index c6ab7ab8..3c9dcc69 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -34,7 +34,13 @@ export async function createChatHandler(request: NextRequest): Promise { + const validated = await validateUpdateChatBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { chatId, topic } = validated; + + try { + const updated = await updateRoom(chatId, { topic }); + if (!updated) { + return NextResponse.json( + { status: "error", error: "Failed to update chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + chat: { + id: updated.id, + account_id: updated.account_id, + topic: updated.topic, + updated_at: updated.updated_at, + artist_id: updated.artist_id, + }, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Server error"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/chats/validateCreateChatBody.ts b/lib/chats/validateCreateChatBody.ts index ac69a609..830114ff 100644 --- a/lib/chats/validateCreateChatBody.ts +++ b/lib/chats/validateCreateChatBody.ts @@ -7,6 +7,7 @@ export const createChatBodySchema = z.object({ chatId: z.string().uuid("chatId must be a valid UUID").optional(), accountId: z.string().uuid("accountId must be a valid UUID").optional(), firstMessage: z.string().optional(), + topic: z.string().optional(), }); export type CreateChatBody = z.infer; diff --git a/lib/chats/validateUpdateChatBody.ts b/lib/chats/validateUpdateChatBody.ts new file mode 100644 index 00000000..63b48f38 --- /dev/null +++ b/lib/chats/validateUpdateChatBody.ts @@ -0,0 +1,104 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { buildGetChatsParams } from "./buildGetChatsParams"; +import { z } from "zod"; + +/** + * Zod schema for PATCH /api/chats request body. + */ +export const updateChatBodySchema = z.object({ + chatId: z.string().uuid("chatId must be a valid UUID"), + topic: z + .string({ message: "topic is required" }) + .min(3, "topic must be between 3 and 50 characters") + .max(50, "topic must be between 3 and 50 characters"), +}); + +export type UpdateChatBody = z.infer; + +/** + * Validated update chat request data. + */ +export interface ValidatedUpdateChat { + chatId: string; + topic: string; +} + +/** + * Validates request for PATCH /api/chats. + * Parses JSON, validates schema, authenticates, verifies room exists, and checks access. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated data if validation passes. + */ +export async function validateUpdateChatBody( + request: NextRequest, +): Promise { + // Parse JSON body + let body: unknown; + try { + body = await request.json(); + } catch { + body = {}; + } + + // Validate body schema + const result = updateChatBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const { chatId, topic } = result.data; + + // Validate authentication + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId, orgId } = authResult; + + // Verify room exists + const room = await selectRoom(chatId); + if (!room) { + return NextResponse.json( + { status: "error", error: "Chat room not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + // Check access control + const { params } = await buildGetChatsParams({ + account_id: accountId, + org_id: orgId, + }); + + // If params.account_ids is undefined, it means admin access (all records) + if (params.account_ids && room.account_id) { + if (!params.account_ids.includes(room.account_id)) { + return NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + } + + return { + chatId, + topic, + }; +} diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index 57a737b9..7cbe4970 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -2,17 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createSandbox } from "../createSandbox"; import { Sandbox } from "@vercel/sandbox"; -import { installClaudeCode } from "../installClaudeCode"; -import { runClaudeCode } from "../runClaudeCode"; const mockSandbox = { sandboxId: "sbx_test123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00Z"), - runCommand: vi.fn(), - writeFiles: vi.fn(), - stop: vi.fn(), }; vi.mock("@vercel/sandbox", () => ({ @@ -28,26 +23,13 @@ vi.mock("ms", () => ({ }), })); -vi.mock("../installClaudeCode", () => ({ - installClaudeCode: vi.fn(), -})); - -vi.mock("../runClaudeCode", () => ({ - runClaudeCode: vi.fn(), -})); - describe("createSandbox", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(installClaudeCode).mockResolvedValue(undefined); - vi.mocked(runClaudeCode).mockResolvedValue(undefined); - mockSandbox.runCommand.mockResolvedValue({ exitCode: 0 }); - mockSandbox.writeFiles.mockResolvedValue(undefined); - mockSandbox.stop.mockResolvedValue(undefined); }); - it("creates sandbox with correct configuration", async () => { - await createSandbox("tell me hello"); + it("creates sandbox with default configuration when no params provided", async () => { + await createSandbox(); expect(Sandbox.create).toHaveBeenCalledWith({ resources: { vcpus: 4 }, @@ -56,21 +38,37 @@ describe("createSandbox", () => { }); }); - it("calls installClaudeCode", async () => { - await createSandbox("tell me hello"); + it("creates sandbox from snapshot when source is provided", async () => { + await createSandbox({ source: { type: "snapshot", snapshotId: "snap_abc123" } }); - expect(installClaudeCode).toHaveBeenCalledWith(mockSandbox); + expect(Sandbox.create).toHaveBeenCalledWith({ + source: { type: "snapshot", snapshotId: "snap_abc123" }, + timeout: 600000, + }); }); - it("calls runClaudeCode with sandbox and prompt", async () => { - const prompt = "tell me hello"; - await createSandbox(prompt); + it("allows overriding default timeout", async () => { + await createSandbox({ timeout: 300000 }); - expect(runClaudeCode).toHaveBeenCalledWith(mockSandbox, prompt); + expect(Sandbox.create).toHaveBeenCalledWith({ + resources: { vcpus: 4 }, + timeout: 300000, + runtime: "node22", + }); + }); + + it("allows overriding default resources", async () => { + await createSandbox({ resources: { vcpus: 2 } }); + + expect(Sandbox.create).toHaveBeenCalledWith({ + resources: { vcpus: 2 }, + timeout: 600000, + runtime: "node22", + }); }); it("returns sandbox created response with sandboxStatus", async () => { - const result = await createSandbox("tell me hello"); + const result = await createSandbox(); expect(result).toEqual({ sandboxId: "sbx_test123", @@ -80,23 +78,15 @@ describe("createSandbox", () => { }); }); - it("stops sandbox after execution", async () => { - await createSandbox("tell me hello"); - - expect(mockSandbox.stop).toHaveBeenCalled(); - }); - - it("stops sandbox if runClaudeCode fails", async () => { - vi.mocked(runClaudeCode).mockRejectedValue(new Error("Failed to run claude code")); - - await expect(createSandbox("tell me hello")).rejects.toThrow("Failed to run claude code"); - expect(mockSandbox.stop).toHaveBeenCalled(); - }); + it("does not stop sandbox after creation", async () => { + const mockSandboxWithStop = { + ...mockSandbox, + stop: vi.fn(), + }; + vi.mocked(Sandbox.create).mockResolvedValue(mockSandboxWithStop as unknown as Sandbox); - it("stops sandbox if installClaudeCode fails", async () => { - vi.mocked(installClaudeCode).mockRejectedValue(new Error("Failed to install")); + await createSandbox(); - await expect(createSandbox("tell me hello")).rejects.toThrow("Failed to install"); - expect(mockSandbox.stop).toHaveBeenCalled(); + expect(mockSandboxWithStop.stop).not.toHaveBeenCalled(); }); }); diff --git a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts index a450b672..3ba597c3 100644 --- a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts @@ -5,6 +5,9 @@ import { NextResponse } from "next/server"; import { createSandboxPostHandler } from "../createSandboxPostHandler"; import { validateSandboxBody } from "@/lib/sandbox/validateSandboxBody"; import { createSandbox } from "@/lib/sandbox/createSandbox"; +import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; +import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/validateSandboxBody", () => ({ validateSandboxBody: vi.fn(), @@ -14,6 +17,18 @@ vi.mock("@/lib/sandbox/createSandbox", () => ({ createSandbox: vi.fn(), })); +vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ + insertAccountSandbox: vi.fn(), +})); + +vi.mock("@/lib/trigger/triggerRunSandboxCommand", () => ({ + triggerRunSandboxCommand: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -41,19 +56,32 @@ describe("createSandboxPostHandler", () => { expect(response.status).toBe(401); }); - it("returns 200 with sandboxes array on success", async () => { + it("returns 200 with sandboxes array including runId on success", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "tell me hello", + command: "ls", }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ sandboxId: "sbx_123", sandboxStatus: "running", timeout: 600000, createdAt: "2024-01-01T00:00:00.000Z", }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_123", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + id: "run_abc123", + }); const request = createMockRequest(); const response = await createSandboxPostHandler(request); @@ -68,18 +96,172 @@ describe("createSandboxPostHandler", () => { sandboxStatus: "running", timeout: 600000, createdAt: "2024-01-01T00:00:00.000Z", + runId: "run_abc123", }, ], }); }); + it("calls createSandbox with snapshotId when account has snapshot", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + id: "snap_record_123", + account_id: "acc_123", + snapshot_id: "snap_xyz", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_456", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_456", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + id: "run_def456", + }); + + const request = createMockRequest(); + await createSandboxPostHandler(request); + + expect(createSandbox).toHaveBeenCalledWith({ + source: { type: "snapshot", snapshotId: "snap_xyz" }, + }); + }); + + it("calls createSandbox with empty params when account has no snapshot", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_456", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_456", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + id: "run_def456", + }); + + const request = createMockRequest(); + await createSandboxPostHandler(request); + + expect(createSandbox).toHaveBeenCalledWith({}); + }); + + it("calls insertAccountSandbox with correct account_id and sandbox_id", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_456", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_456", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + id: "run_def456", + }); + + const request = createMockRequest(); + await createSandboxPostHandler(request); + + expect(insertAccountSandbox).toHaveBeenCalledWith({ + account_id: "acc_123", + sandbox_id: "sbx_456", + }); + }); + + it("calls triggerRunSandboxCommand with command, args, cwd, sandboxId, and accountId", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + args: ["-la"], + cwd: "/home", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_789", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_789", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + id: "run_ghi789", + }); + + const request = createMockRequest(); + await createSandboxPostHandler(request); + + expect(triggerRunSandboxCommand).toHaveBeenCalledWith({ + command: "ls", + args: ["-la"], + cwd: "/home", + sandboxId: "sbx_789", + accountId: "acc_123", + }); + }); + it("returns 400 with error status when createSandbox throws", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "tell me hello", + command: "ls", }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed")); const request = createMockRequest(); @@ -92,4 +274,121 @@ describe("createSandboxPostHandler", () => { error: "Sandbox creation failed", }); }); + + it("returns 400 with error status when insertAccountSandbox throws", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockRejectedValue(new Error("Database insert failed")); + + const request = createMockRequest(); + const response = await createSandboxPostHandler(request); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json).toEqual({ + status: "error", + error: "Database insert failed", + }); + }); + + it("returns 200 without runId and skips trigger when command is not provided", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + // command is not provided (optional) + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_123", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + const response = await createSandboxPostHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + status: "success", + sandboxes: [ + { + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + // Note: runId is not included when command is not provided + }, + ], + }); + // Verify triggerRunSandboxCommand was NOT called + expect(triggerRunSandboxCommand).not.toHaveBeenCalled(); + }); + + it("returns 200 without runId when triggerRunSandboxCommand throws", async () => { + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + command: "ls", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(createSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_123", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + vi.mocked(triggerRunSandboxCommand).mockRejectedValue(new Error("Task trigger failed")); + + const request = createMockRequest(); + const response = await createSandboxPostHandler(request); + + // Sandbox was created successfully, so return 200 even if trigger fails + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + status: "success", + sandboxes: [ + { + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + // Note: runId is not included when trigger fails + }, + ], + }); + }); }); diff --git a/lib/sandbox/__tests__/getSandboxStatus.test.ts b/lib/sandbox/__tests__/getSandboxStatus.test.ts new file mode 100644 index 00000000..3f84615a --- /dev/null +++ b/lib/sandbox/__tests__/getSandboxStatus.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Sandbox } from "@vercel/sandbox"; + +import { getSandboxStatus } from "../getSandboxStatus"; + +vi.mock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn(), + }, +})); + +describe("getSandboxStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns sandbox status when sandbox exists", async () => { + const mockSandbox = { + sandboxId: "sbx_123", + status: "running", + timeout: 600000, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }; + vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); + + const result = await getSandboxStatus("sbx_123"); + + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); + expect(result).toEqual({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + }); + + it("returns null when sandbox is not found", async () => { + vi.mocked(Sandbox.get).mockRejectedValue(new Error("Sandbox not found")); + + const result = await getSandboxStatus("sbx_nonexistent"); + + expect(result).toBeNull(); + }); + + it("returns null on API error", async () => { + vi.mocked(Sandbox.get).mockRejectedValue(new Error("API error")); + + const result = await getSandboxStatus("sbx_123"); + + expect(result).toBeNull(); + }); + + it("handles stopped sandbox status", async () => { + const mockSandbox = { + sandboxId: "sbx_stopped", + status: "stopped", + timeout: 0, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }; + vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); + + const result = await getSandboxStatus("sbx_stopped"); + + expect(result).toEqual({ + sandboxId: "sbx_stopped", + sandboxStatus: "stopped", + timeout: 0, + createdAt: "2024-01-01T00:00:00.000Z", + }); + }); + + it("handles pending sandbox status", async () => { + const mockSandbox = { + sandboxId: "sbx_pending", + status: "pending", + timeout: 600000, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }; + vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); + + const result = await getSandboxStatus("sbx_pending"); + + expect(result?.sandboxStatus).toBe("pending"); + }); +}); diff --git a/lib/sandbox/__tests__/getSandboxesHandler.test.ts b/lib/sandbox/__tests__/getSandboxesHandler.test.ts new file mode 100644 index 00000000..3c762bde --- /dev/null +++ b/lib/sandbox/__tests__/getSandboxesHandler.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getSandboxesHandler } from "../getSandboxesHandler"; +import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; +import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { getSandboxStatus } from "../getSandboxStatus"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; + +vi.mock("../validateGetSandboxesRequest", () => ({ + validateGetSandboxesRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_sandboxes/selectAccountSandboxes", () => ({ + selectAccountSandboxes: vi.fn(), +})); + +vi.mock("../getSandboxStatus", () => ({ + getSandboxStatus: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +/** + * Creates a mock NextRequest for testing. + * + * @returns A mock NextRequest object + */ +function createMockRequest(): NextRequest { + return { + url: "http://localhost:3000/api/sandboxes", + headers: new Headers({ "x-api-key": "test-key" }), + } as unknown as NextRequest; +} + +describe("getSandboxesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock for selectAccountSnapshots - no snapshot exists + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + }); + + it("returns error response when validation fails", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(401); + }); + + it("returns 200 with empty sandboxes array when no sandboxes exist", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + status: "success", + sandboxes: [], + snapshot_id: null, + github_repo: null, + }); + }); + + it("returns 200 with sandboxes array on success", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([ + { + id: "record_1", + account_id: "acc_123", + sandbox_id: "sbx_123", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + vi.mocked(getSandboxStatus).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + status: "success", + sandboxes: [ + { + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + ], + snapshot_id: null, + github_repo: null, + }); + }); + + it("filters out sandboxes that return null status", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([ + { + id: "record_1", + account_id: "acc_123", + sandbox_id: "sbx_valid", + created_at: "2024-01-01T00:00:00.000Z", + }, + { + id: "record_2", + account_id: "acc_123", + sandbox_id: "sbx_deleted", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + vi.mocked(getSandboxStatus) + .mockResolvedValueOnce({ + sandboxId: "sbx_valid", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }) + .mockResolvedValueOnce(null); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.sandboxes).toHaveLength(1); + expect(json.sandboxes[0].sandboxId).toBe("sbx_valid"); + }); + + it("calls selectAccountSandboxes with validated params", async () => { + const validatedParams = { + accountIds: ["acc_123"], + sandboxId: "sbx_specific", + }; + vi.mocked(validateGetSandboxesRequest).mockResolvedValue(validatedParams); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + + const request = createMockRequest(); + await getSandboxesHandler(request); + + expect(selectAccountSandboxes).toHaveBeenCalledWith(validatedParams); + }); + + it("calls getSandboxStatus for each sandbox record", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([ + { + id: "record_1", + account_id: "acc_123", + sandbox_id: "sbx_1", + created_at: "2024-01-01T00:00:00.000Z", + }, + { + id: "record_2", + account_id: "acc_123", + sandbox_id: "sbx_2", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + vi.mocked(getSandboxStatus).mockResolvedValue(null); + + const request = createMockRequest(); + await getSandboxesHandler(request); + + expect(getSandboxStatus).toHaveBeenCalledTimes(2); + expect(getSandboxStatus).toHaveBeenCalledWith("sbx_1"); + expect(getSandboxStatus).toHaveBeenCalledWith("sbx_2"); + }); + + it("fetches sandbox statuses in parallel, not sequentially", async () => { + const callOrder: string[] = []; + + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([ + { + id: "record_1", + account_id: "acc_123", + sandbox_id: "sbx_1", + created_at: "2024-01-01T00:00:00.000Z", + }, + { + id: "record_2", + account_id: "acc_123", + sandbox_id: "sbx_2", + created_at: "2024-01-01T00:00:00.000Z", + }, + { + id: "record_3", + account_id: "acc_123", + sandbox_id: "sbx_3", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + + // Mock that tracks when calls start and complete + vi.mocked(getSandboxStatus).mockImplementation(async (sandboxId: string) => { + callOrder.push(`start:${sandboxId}`); + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, 10)); + callOrder.push(`end:${sandboxId}`); + return { + sandboxId, + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }; + }); + + const request = createMockRequest(); + await getSandboxesHandler(request); + + // In parallel execution, all starts should happen before any ends + // Pattern should be: start:1, start:2, start:3, end:1, end:2, end:3 + // In sequential execution: start:1, end:1, start:2, end:2, start:3, end:3 + const startIndices = callOrder + .map((item, index) => (item.startsWith("start:") ? index : -1)) + .filter(i => i !== -1); + const endIndices = callOrder + .map((item, index) => (item.startsWith("end:") ? index : -1)) + .filter(i => i !== -1); + + // All starts should come before all ends (parallel execution) + const maxStartIndex = Math.max(...startIndices); + const minEndIndex = Math.min(...endIndices); + expect(maxStartIndex).toBeLessThan(minEndIndex); + }); + + describe("snapshot_id and github_repo fields", () => { + it("returns snapshot_id and github_repo when account has a snapshot", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/user/repo", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.snapshot_id).toBe("snap_abc123"); + expect(json.github_repo).toBe("https://github.com/user/repo"); + }); + + it("returns null for snapshot_id and github_repo when account has no snapshot", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.snapshot_id).toBeNull(); + expect(json.github_repo).toBeNull(); + }); + + it("returns null github_repo when snapshot exists but has no github_repo", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: null, + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.snapshot_id).toBe("snap_abc123"); + expect(json.github_repo).toBeNull(); + }); + + it("returns snapshot info for org keys using orgId as accountId", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + orgId: "org_123", + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "org_123", + snapshot_id: "snap_org_abc", + github_repo: "https://github.com/org/repo", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.snapshot_id).toBe("snap_org_abc"); + expect(json.github_repo).toBe("https://github.com/org/repo"); + expect(selectAccountSnapshots).toHaveBeenCalledWith("org_123"); + }); + + it("calls selectAccountSnapshots with the account ID", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + + const request = createMockRequest(); + await getSandboxesHandler(request); + + expect(selectAccountSnapshots).toHaveBeenCalledWith("acc_123"); + }); + }); +}); diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts new file mode 100644 index 00000000..95b51a6b --- /dev/null +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { updateSnapshotPatchHandler } from "../updateSnapshotPatchHandler"; +import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; +import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; + +vi.mock("@/lib/sandbox/validateSnapshotPatchBody", () => ({ + validateSnapshotPatchBody: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ + upsertAccountSnapshot: vi.fn(), +})); + +/** + * Creates a mock NextRequest for testing. + * + * @returns A mock NextRequest object + */ +function createMockRequest(): NextRequest { + return { + headers: new Headers({ "x-api-key": "test-key" }), + } as unknown as NextRequest; +} + +describe("updateSnapshotPatchHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns error response when validation fails", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(response.status).toBe(401); + }); + + it("returns 200 with success and snapshotId on successful upsert", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + snapshotId: "snap_abc123", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_123", + snapshot_id: "snap_abc123", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + success: true, + snapshotId: "snap_abc123", + }); + }); + + it("calls upsertAccountSnapshot with correct accountId and snapshotId", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_456", + orgId: "org_789", + authToken: "token", + snapshotId: "snap_xyz", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_456", + snapshot_id: "snap_xyz", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).toHaveBeenCalledWith({ + accountId: "acc_456", + snapshotId: "snap_xyz", + }); + }); + + it("returns 400 when upsertAccountSnapshot returns error", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + snapshotId: "snap_abc123", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: null, + error: { message: "Database error", details: "", hint: "", code: "23503" }, + }); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json).toEqual({ + status: "error", + error: "Failed to update snapshot", + }); + }); + + it("returns 400 when upsertAccountSnapshot throws error", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + snapshotId: "snap_abc123", + }); + vi.mocked(upsertAccountSnapshot).mockRejectedValue(new Error("Unexpected error")); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json).toEqual({ + status: "error", + error: "Unexpected error", + }); + }); +}); diff --git a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts new file mode 100644 index 00000000..98660d7f --- /dev/null +++ b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +/** + * Creates a mock NextRequest for testing. + * + * @param queryParams - Optional query parameters + * @returns A mock NextRequest object + */ +function createMockRequest(queryParams: Record = {}): NextRequest { + const url = new URL("http://localhost:3000/api/sandboxes"); + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + return { + url: url.toString(), + headers: new Headers({ "x-api-key": "test-key" }), + } as unknown as NextRequest; +} + +describe("validateGetSandboxesRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns error when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns params with accountIds for personal key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: undefined, + }); + }); + + it("returns params with orgId for org key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + orgId: "org_456", + sandboxId: undefined, + }); + }); + + it("includes sandbox_id in params when provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest({ sandbox_id: "sbx_specific" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: "sbx_specific", + }); + }); + + it("handles empty sandbox_id query param", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as { sandboxId?: string }).sandboxId).toBeUndefined(); + }); +}); diff --git a/lib/sandbox/__tests__/validateSandboxBody.test.ts b/lib/sandbox/__tests__/validateSandboxBody.test.ts index 3aa37cb1..7beb2e2a 100644 --- a/lib/sandbox/__tests__/validateSandboxBody.test.ts +++ b/lib/sandbox/__tests__/validateSandboxBody.test.ts @@ -41,13 +41,13 @@ describe("validateSandboxBody", () => { expect((result as NextResponse).status).toBe(401); }); - it("returns validated body with auth context when prompt is provided", async () => { + it("returns validated body with auth context when command is provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ prompt: "tell me hello" }); + vi.mocked(safeParseJson).mockResolvedValue({ command: "ls" }); const request = createMockRequest(); const result = await validateSandboxBody(request); @@ -56,11 +56,36 @@ describe("validateSandboxBody", () => { accountId: "acc_123", orgId: "org_456", authToken: "token", - prompt: "tell me hello", + command: "ls", }); }); - it("returns error response when prompt is missing", async () => { + it("returns validated body with optional args and cwd", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + vi.mocked(safeParseJson).mockResolvedValue({ + command: "ls", + args: ["-la", "/home"], + cwd: "/tmp", + }); + + const request = createMockRequest(); + const result = await validateSandboxBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + command: "ls", + args: ["-la", "/home"], + cwd: "/tmp", + }); + }); + + it("returns validated body when command is omitted (optional)", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -71,17 +96,20 @@ describe("validateSandboxBody", () => { const request = createMockRequest(); const result = await validateSandboxBody(request); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); + expect(result).toEqual({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); }); - it("returns error response when prompt is empty string", async () => { + it("returns error response when command is empty string", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ prompt: "" }); + vi.mocked(safeParseJson).mockResolvedValue({ command: "" }); const request = createMockRequest(); const result = await validateSandboxBody(request); diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts new file mode 100644 index 00000000..af6634d8 --- /dev/null +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateSnapshotPatchBody } from "../validateSnapshotPatchBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +/** + * Creates a mock NextRequest for testing. + * + * @returns A mock NextRequest object + */ +function createMockRequest(): NextRequest { + return { + headers: new Headers({ "x-api-key": "test-key" }), + } as unknown as NextRequest; +} + +describe("validateSnapshotPatchBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123" }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns validated body when snapshotId is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123" }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + snapshotId: "snap_abc123", + }); + }); + + it("returns error response when snapshotId is missing", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + const json = await (result as NextResponse).json(); + expect(json.error).toContain("snapshotId"); + }); + + it("returns error response when snapshotId is empty string", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "" }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("passes account_id to validateAuthContext when provided", async () => { + const targetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: targetAccountId, + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: targetAccountId, + orgId: "org_789", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: targetAccountId, + }); + expect(result).toEqual({ + accountId: targetAccountId, + snapshotId: "snap_abc123", + }); + }); + + it("returns 403 when account_id override is denied", async () => { + const unauthorizedAccountId = "660e8400-e29b-41d4-a716-446655440001"; + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: unauthorizedAccountId, + }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Access denied to specified account_id" }, { status: 403 }), + ); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns error when account_id is not a valid UUID", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + account_id: "not-a-uuid", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + const json = await (result as NextResponse).json(); + expect(json.error).toContain("account_id"); + }); +}); diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index fcb36aa2..570621af 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -1,7 +1,5 @@ import ms from "ms"; import { Sandbox } from "@vercel/sandbox"; -import { installClaudeCode } from "./installClaudeCode"; -import { runClaudeCode } from "./runClaudeCode"; export interface SandboxCreatedResponse { sandboxId: Sandbox["sandboxId"]; @@ -10,31 +8,45 @@ export interface SandboxCreatedResponse { createdAt: string; } +/** Extract CreateSandboxParams from Sandbox.create method signature */ +export type CreateSandboxParams = NonNullable[0]>; + +const DEFAULT_TIMEOUT = ms("10m"); +const DEFAULT_VCPUS = 4; +const DEFAULT_RUNTIME = "node22"; + /** - * Creates a Vercel Sandbox, installs Claude Code CLI and Anthropic SDK, then executes a prompt. + * Creates a Vercel Sandbox and returns its info. + * + * The sandbox is left running so that commands can be executed via the runSandboxCommand task. + * Accepts the same parameters as Sandbox.create from @vercel/sandbox. * - * @param prompt - The prompt to send to Claude + * @param params - Sandbox creation parameters (source, timeout, resources, runtime, ports) * @returns The sandbox creation response - * @throws Error if sandbox creation or dependency installation fails + * @throws Error if sandbox creation fails */ -export async function createSandbox(prompt: string): Promise { - const sandbox = await Sandbox.create({ - resources: { vcpus: 4 }, - timeout: ms("10m"), - runtime: "node22", - }); +export async function createSandbox(params: CreateSandboxParams = {}): Promise { + const hasSnapshotSource = params.source && "type" in params.source && params.source.type === "snapshot"; - try { - await installClaudeCode(sandbox); - await runClaudeCode(sandbox, prompt); + // Pass params directly to SDK - it handles all the type variants + const sandbox = await Sandbox.create( + hasSnapshotSource + ? { + ...params, + timeout: params.timeout ?? DEFAULT_TIMEOUT, + } + : { + resources: { vcpus: DEFAULT_VCPUS }, + timeout: params.timeout ?? DEFAULT_TIMEOUT, + runtime: DEFAULT_RUNTIME, + ...params, + }, + ); - return { - sandboxId: sandbox.sandboxId, - sandboxStatus: sandbox.status, - timeout: sandbox.timeout, - createdAt: sandbox.createdAt.toISOString(), - }; - } finally { - await sandbox.stop(); - } + return { + sandboxId: sandbox.sandboxId, + sandboxStatus: sandbox.status, + timeout: sandbox.timeout, + createdAt: sandbox.createdAt.toISOString(), + }; } diff --git a/lib/sandbox/createSandboxPostHandler.ts b/lib/sandbox/createSandboxPostHandler.ts index 2a90ba83..86611710 100644 --- a/lib/sandbox/createSandboxPostHandler.ts +++ b/lib/sandbox/createSandboxPostHandler.ts @@ -3,15 +3,21 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createSandbox } from "@/lib/sandbox/createSandbox"; import { validateSandboxBody } from "@/lib/sandbox/validateSandboxBody"; +import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; +import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; /** * Handler for POST /api/sandboxes. * - * Creates a Vercel Sandbox with Claude's Agent SDK pre-installed and executes a prompt. + * Creates a Vercel Sandbox (from account's snapshot if available, otherwise fresh). + * If a command is provided, triggers the run-sandbox-command task to execute it. + * If no command is provided, simply creates the sandbox without running any command. * Requires authentication via x-api-key header or Authorization Bearer token. + * Saves sandbox info to the account_sandboxes table. * * @param request - The request object - * @returns A NextResponse with sandbox creation result or error + * @returns A NextResponse with sandbox creation result (includes runId only if command was provided) */ export async function createSandboxPostHandler(request: NextRequest): Promise { const validated = await validateSandboxBody(request); @@ -20,10 +26,47 @@ export async function createSandboxPostHandler(request: NextRequest): Promise { + try { + const sandbox = await Sandbox.get({ sandboxId }); + + return { + sandboxId: sandbox.sandboxId, + sandboxStatus: sandbox.status, + timeout: sandbox.timeout, + createdAt: sandbox.createdAt.toISOString(), + }; + } catch (error) { + console.error(`Error fetching sandbox ${sandboxId}:`, error); + return null; + } +} diff --git a/lib/sandbox/getSandboxesHandler.ts b/lib/sandbox/getSandboxesHandler.ts new file mode 100644 index 00000000..e1baaed4 --- /dev/null +++ b/lib/sandbox/getSandboxesHandler.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetSandboxesRequest } from "./validateGetSandboxesRequest"; +import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getSandboxStatus } from "./getSandboxStatus"; +import type { SandboxCreatedResponse } from "./createSandbox"; + +/** + * Handler for retrieving sandbox statuses for an account. + * Requires authentication via x-api-key header or Authorization bearer token. + * + * For personal keys: Returns sandboxes for the key owner's account. + * For org keys: Returns sandboxes for all accounts in the organization. + * + * Optional query parameters: + * - sandbox_id: Filter to a specific sandbox (must belong to account/org) + * + * @param request - The request object. + * @returns A NextResponse with array of sandbox statuses, plus snapshot_id and github_repo. + */ +export async function getSandboxesHandler(request: NextRequest): Promise { + const validated = await validateGetSandboxesRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + // Determine account ID for snapshot lookup + const snapshotAccountId = + validated.accountIds?.length === 1 ? validated.accountIds[0] : validated.orgId; + + // Fetch sandbox records and snapshot info in parallel + const [accountSandboxes, snapshots] = await Promise.all([ + selectAccountSandboxes(validated), + snapshotAccountId ? selectAccountSnapshots(snapshotAccountId) : Promise.resolve([]), + ]); + + // Fetch live status for each sandbox from Vercel API in parallel + const statusResults = await Promise.all( + accountSandboxes.map(record => getSandboxStatus(record.sandbox_id)), + ); + + // Filter out null results (sandboxes that no longer exist) + const sandboxes = statusResults.filter( + (status): status is SandboxCreatedResponse => status !== null, + ); + + // Extract snapshot info + const snapshot_id = snapshots[0]?.snapshot_id ?? null; + const github_repo = snapshots[0]?.github_repo ?? null; + + return NextResponse.json( + { + status: "success", + sandboxes, + snapshot_id, + github_repo, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); +} diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts new file mode 100644 index 00000000..b1ea91be --- /dev/null +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; +import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; + +/** + * Handler for PATCH /api/sandboxes/snapshot. + * + * Updates the snapshot ID for an account. This snapshot will be used + * as the base environment when creating new sandboxes. + * Requires authentication via x-api-key header or Authorization Bearer token. + * + * @param request - The request object + * @returns A NextResponse with the updated snapshot ID or error + */ +export async function updateSnapshotPatchHandler(request: NextRequest): Promise { + const validated = await validateSnapshotPatchBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const result = await upsertAccountSnapshot({ + accountId: validated.accountId, + snapshotId: validated.snapshotId, + }); + + if (result.error || !result.data) { + return NextResponse.json( + { status: "error", error: "Failed to update snapshot" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + success: true, + snapshotId: result.data.snapshot_id, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update snapshot"; + return NextResponse.json( + { status: "error", error: message }, + { status: 400, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/sandbox/validateGetSandboxesRequest.ts b/lib/sandbox/validateGetSandboxesRequest.ts new file mode 100644 index 00000000..aa2d73bb --- /dev/null +++ b/lib/sandbox/validateGetSandboxesRequest.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { z } from "zod"; + +const getSandboxesQuerySchema = z.object({ + sandbox_id: z.string().optional(), +}); + +/** + * Validates GET /api/sandboxes request. + * Handles authentication via x-api-key or Authorization bearer token. + * + * Query parameters: + * - sandbox_id: Filter to a specific sandbox (must belong to account/org) + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or SelectAccountSandboxesParams + */ +export async function validateGetSandboxesRequest( + request: NextRequest, +): Promise { + // Parse query parameters + const { searchParams } = new URL(request.url); + const queryParams = { + sandbox_id: searchParams.get("sandbox_id") ?? undefined, + }; + + const queryResult = getSandboxesQuerySchema.safeParse(queryParams); + if (!queryResult.success) { + const firstError = queryResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const { sandbox_id: sandboxId } = queryResult.data; + + // Use validateAuthContext for authentication + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId, orgId } = authResult; + + // Build params based on auth context + const params: SelectAccountSandboxesParams = { + sandboxId, + }; + + if (orgId) { + // Org key - filter by org membership + params.orgId = orgId; + } else { + // Personal key - filter by account + params.accountIds = [accountId]; + } + + return params; +} diff --git a/lib/sandbox/validateSandboxBody.ts b/lib/sandbox/validateSandboxBody.ts index e9e7d789..1d8c6085 100644 --- a/lib/sandbox/validateSandboxBody.ts +++ b/lib/sandbox/validateSandboxBody.ts @@ -6,13 +6,15 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; export const sandboxBodySchema = z.object({ - prompt: z.string({ message: "prompt is required" }).min(1, "prompt cannot be empty"), + command: z.string().min(1, "command cannot be empty").optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), }); export type SandboxBody = z.infer & AuthContext; /** - * Validates auth and request body for POST /api/sandbox. + * Validates auth and request body for POST /api/sandboxes. * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated body with auth context. diff --git a/lib/sandbox/validateSnapshotPatchBody.ts b/lib/sandbox/validateSnapshotPatchBody.ts new file mode 100644 index 00000000..08f33c17 --- /dev/null +++ b/lib/sandbox/validateSnapshotPatchBody.ts @@ -0,0 +1,63 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { z } from "zod"; + +export const snapshotPatchBodySchema = z.object({ + snapshotId: z.string({ message: "snapshotId is required" }).min(1, "snapshotId cannot be empty"), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type SnapshotPatchBody = { + /** The account ID to update */ + accountId: string; + /** The snapshot ID to set */ + snapshotId: string; +}; + +/** + * Validates auth and request body for PATCH /api/sandboxes/snapshot. + * Handles authentication via x-api-key or Authorization bearer token, + * body validation, and optional account_id override for organization API keys. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated body with auth context. + */ +export async function validateSnapshotPatchBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = snapshotPatchBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const { snapshotId, account_id: targetAccountId } = result.data; + + const authResult = await validateAuthContext(request, { + accountId: targetAccountId, + }); + + if (authResult instanceof NextResponse) { + return authResult; + } + + return { + accountId: authResult.accountId, + snapshotId, + }; +} diff --git a/lib/supabase/account_sandboxes/__tests__/insertAccountSandbox.test.ts b/lib/supabase/account_sandboxes/__tests__/insertAccountSandbox.test.ts new file mode 100644 index 00000000..15e605d9 --- /dev/null +++ b/lib/supabase/account_sandboxes/__tests__/insertAccountSandbox.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { insertAccountSandbox } from "../insertAccountSandbox"; + +describe("insertAccountSandbox", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ insert: mockInsert }); + mockInsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + }); + + it("inserts an account sandbox record and returns data on success", async () => { + const mockData = { + id: "sandbox-record-123", + account_id: "account-456", + sandbox_id: "sbx_789", + created_at: "2024-01-01T00:00:00.000Z", + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await insertAccountSandbox({ + account_id: "account-456", + sandbox_id: "sbx_789", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_sandboxes"); + expect(mockInsert).toHaveBeenCalledWith({ + account_id: "account-456", + sandbox_id: "sbx_789", + }); + expect(result).toEqual({ data: mockData, error: null }); + }); + + it("returns error when insert fails", async () => { + const mockError = { message: "Insert failed", code: "23505" }; + mockSingle.mockResolvedValue({ data: null, error: mockError }); + + const result = await insertAccountSandbox({ + account_id: "account-456", + sandbox_id: "sbx_789", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_sandboxes"); + expect(result).toEqual({ data: null, error: mockError }); + }); + + it("returns error when account_id foreign key constraint fails", async () => { + const mockError = { + message: 'insert or update on table "account_sandboxes" violates foreign key constraint', + code: "23503", + }; + mockSingle.mockResolvedValue({ data: null, error: mockError }); + + const result = await insertAccountSandbox({ + account_id: "non-existent-account", + sandbox_id: "sbx_789", + }); + + expect(result).toEqual({ data: null, error: mockError }); + }); +}); diff --git a/lib/supabase/account_sandboxes/__tests__/selectAccountSandboxes.test.ts b/lib/supabase/account_sandboxes/__tests__/selectAccountSandboxes.test.ts new file mode 100644 index 00000000..be02e565 --- /dev/null +++ b/lib/supabase/account_sandboxes/__tests__/selectAccountSandboxes.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { selectAccountSandboxes } from "../selectAccountSandboxes"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockEq = vi.fn(); +const mockIn = vi.fn(); +const mockOrder = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAccountSandboxes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ eq: mockEq, in: mockIn, order: mockOrder }); + mockEq.mockReturnValue({ in: mockIn, order: mockOrder }); + mockIn.mockReturnValue({ order: mockOrder }); + mockOrder.mockResolvedValue({ data: [], error: null }); + }); + + it("returns sandboxes for given accountIds", async () => { + const mockData = [ + { + id: "record-1", + account_id: "account-123", + sandbox_id: "sbx_abc", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]; + mockOrder.mockResolvedValue({ data: mockData, error: null }); + + const result = await selectAccountSandboxes({ + accountIds: ["account-123"], + }); + + expect(mockFrom).toHaveBeenCalledWith("account_sandboxes"); + expect(mockIn).toHaveBeenCalledWith("account_id", ["account-123"]); + expect(result).toEqual(mockData); + }); + + it("filters by sandbox_id when provided", async () => { + const mockData = [ + { + id: "record-1", + account_id: "account-123", + sandbox_id: "sbx_specific", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]; + mockEq.mockReturnValue({ in: mockIn, order: mockOrder }); + mockOrder.mockResolvedValue({ data: mockData, error: null }); + + const result = await selectAccountSandboxes({ + accountIds: ["account-123"], + sandboxId: "sbx_specific", + }); + + expect(mockEq).toHaveBeenCalledWith("sandbox_id", "sbx_specific"); + expect(result).toEqual(mockData); + }); + + it("returns empty array when no sandboxes found", async () => { + mockOrder.mockResolvedValue({ data: [], error: null }); + + const result = await selectAccountSandboxes({ + accountIds: ["account-123"], + }); + + expect(result).toEqual([]); + }); + + it("returns empty array on database error", async () => { + mockOrder.mockResolvedValue({ + data: null, + error: { message: "Database error", code: "500" }, + }); + + const result = await selectAccountSandboxes({ + accountIds: ["account-123"], + }); + + expect(result).toEqual([]); + }); + + it("queries org accounts when orgId is provided", async () => { + const mockOrgAccounts = [{ account_id: "org-account-1" }, { account_id: "org-account-2" }]; + const mockSandboxes = [ + { + id: "record-1", + account_id: "org-account-1", + sandbox_id: "sbx_org", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]; + + // Mock the org accounts query + const mockOrgSelect = vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: mockOrgAccounts, error: null }), + }); + + // First call is for account_sandboxes, second is for account_organization_ids + let callCount = 0; + mockFrom.mockImplementation((table: string) => { + if (table === "account_organization_ids") { + return { select: mockOrgSelect }; + } + callCount++; + return { select: mockSelect }; + }); + + mockOrder.mockResolvedValue({ data: mockSandboxes, error: null }); + + const result = await selectAccountSandboxes({ + orgId: "org-123", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_organization_ids"); + expect(mockIn).toHaveBeenCalledWith("account_id", ["org-account-1", "org-account-2"]); + expect(result).toEqual(mockSandboxes); + }); + + it("returns empty array when org has no accounts", async () => { + const mockOrgSelect = vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }), + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "account_organization_ids") { + return { select: mockOrgSelect }; + } + return { select: mockSelect }; + }); + + const result = await selectAccountSandboxes({ + orgId: "org-empty", + }); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/account_sandboxes/insertAccountSandbox.ts b/lib/supabase/account_sandboxes/insertAccountSandbox.ts new file mode 100644 index 00000000..5e761d98 --- /dev/null +++ b/lib/supabase/account_sandboxes/insertAccountSandbox.ts @@ -0,0 +1,33 @@ +import type { PostgrestError } from "@supabase/supabase-js"; +import supabase from "../serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +type InsertAccountSandboxResult = + | { data: Tables<"account_sandboxes">; error: null } + | { data: null; error: PostgrestError }; + +/** + * Inserts an account sandbox record into the database. + * + * @param input - The input object containing account_id and sandbox_id + * @param input.account_id - The account UUID + * @param input.sandbox_id - The sandbox identifier from Vercel + * @returns The inserted account sandbox record or error + */ +export async function insertAccountSandbox({ + account_id, + sandbox_id, +}: TablesInsert<"account_sandboxes">): Promise { + const { data, error } = await supabase + .from("account_sandboxes") + .insert({ + account_id, + sandbox_id, + }) + .select() + .single(); + + if (error) return { data: null, error }; + + return { data, error: null }; +} diff --git a/lib/supabase/account_sandboxes/selectAccountSandboxes.ts b/lib/supabase/account_sandboxes/selectAccountSandboxes.ts new file mode 100644 index 00000000..7ab11f48 --- /dev/null +++ b/lib/supabase/account_sandboxes/selectAccountSandboxes.ts @@ -0,0 +1,58 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +export interface SelectAccountSandboxesParams { + accountIds?: string[]; + orgId?: string | null; + sandboxId?: string; +} + +/** + * Selects account sandbox records from the database. + * + * @param params - Query parameters + * @param params.accountIds - Filter by specific account IDs + * @param params.orgId - Filter by organization membership + * @param params.sandboxId - Filter by specific sandbox ID + * @returns Array of account sandbox records + */ +export async function selectAccountSandboxes({ + accountIds, + orgId, + sandboxId, +}: SelectAccountSandboxesParams): Promise[]> { + let query = supabase.from("account_sandboxes").select("*"); + + if (sandboxId) { + query = query.eq("sandbox_id", sandboxId); + } + + if (accountIds && accountIds.length > 0) { + query = query.in("account_id", accountIds); + } else if (orgId) { + // Get accounts in the organization + const { data: orgAccounts } = await supabase + .from("account_organization_ids") + .select("account_id") + .eq("organization_id", orgId); + + if (orgAccounts && orgAccounts.length > 0) { + const orgAccountIds = orgAccounts.map(a => a.account_id); + query = query.in("account_id", orgAccountIds); + } else { + // No accounts in org, return empty + return []; + } + } + + query = query.order("created_at", { ascending: false }); + + const { data, error } = await query; + + if (error) { + console.error("Error fetching account sandboxes:", error); + return []; + } + + return data || []; +} diff --git a/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts new file mode 100644 index 00000000..ce8e8a80 --- /dev/null +++ b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { upsertAccountSnapshot } from "../upsertAccountSnapshot"; + +const mockFrom = vi.fn(); +const mockUpsert = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("upsertAccountSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ upsert: mockUpsert }); + mockUpsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + }); + + it("upserts an account snapshot record and returns data on success", async () => { + const mockData = { + account_id: "account-456", + snapshot_id: "snap_abc123", + expires_at: "2024-12-31T23:59:59.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await upsertAccountSnapshot({ + accountId: "account-456", + snapshotId: "snap_abc123", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "account-456", + snapshot_id: "snap_abc123", + }), + { onConflict: "account_id" }, + ); + expect(result).toEqual({ data: mockData, error: null }); + }); + + it("returns data when updating existing snapshot", async () => { + const mockData = { + account_id: "account-456", + snapshot_id: "snap_new789", + expires_at: "2024-12-31T23:59:59.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await upsertAccountSnapshot({ + accountId: "account-456", + snapshotId: "snap_new789", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); + expect(result).toEqual({ data: mockData, error: null }); + }); + + it("returns error when upsert fails", async () => { + const mockError = { message: "Upsert failed", code: "23505" }; + mockSingle.mockResolvedValue({ data: null, error: mockError }); + + const result = await upsertAccountSnapshot({ + accountId: "account-456", + snapshotId: "snap_abc123", + }); + + expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); + expect(result).toEqual({ data: null, error: mockError }); + }); + + it("returns error when account_id foreign key constraint fails", async () => { + const mockError = { + message: 'insert or update on table "account_snapshots" violates foreign key constraint', + code: "23503", + }; + mockSingle.mockResolvedValue({ data: null, error: mockError }); + + const result = await upsertAccountSnapshot({ + accountId: "non-existent-account", + snapshotId: "snap_abc123", + }); + + expect(result).toEqual({ data: null, error: mockError }); + }); +}); diff --git a/lib/supabase/account_snapshots/selectAccountSnapshots.ts b/lib/supabase/account_snapshots/selectAccountSnapshots.ts new file mode 100644 index 00000000..89139725 --- /dev/null +++ b/lib/supabase/account_snapshots/selectAccountSnapshots.ts @@ -0,0 +1,25 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects snapshots for an account, ordered by creation date (newest first). + * + * @param accountId - The account ID to get snapshots for + * @returns Array of snapshot records, or empty array if none found + */ +export async function selectAccountSnapshots( + accountId: string, +): Promise[]> { + const { data, error } = await supabase + .from("account_snapshots") + .select("*") + .eq("account_id", accountId) + .order("created_at", { ascending: false }); + + if (error) { + // Table might not exist or query failed - return empty array + return []; + } + + return data ?? []; +} diff --git a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts new file mode 100644 index 00000000..e5dba837 --- /dev/null +++ b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts @@ -0,0 +1,47 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; +import type { PostgrestError } from "@supabase/supabase-js"; + +interface UpsertAccountSnapshotParams { + accountId: string; + snapshotId: string; +} + +interface UpsertAccountSnapshotResult { + data: Tables<"account_snapshots"> | null; + error: PostgrestError | null; +} + +/** + * Upserts an account snapshot record. + * Creates a new record if one doesn't exist for the account, + * or updates the existing record if one already exists. + * + * @param params - The upsert parameters + * @param params.accountId - The account ID to associate with the snapshot + * @param params.snapshotId - The snapshot ID to set for the account + * @returns The upserted record or error + */ +export async function upsertAccountSnapshot({ + accountId, + snapshotId, +}: UpsertAccountSnapshotParams): Promise { + // Set expiration to 1 year from now + const expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + const { data, error } = await supabase + .from("account_snapshots") + .upsert( + { + account_id: accountId, + snapshot_id: snapshotId, + expires_at: expiresAt.toISOString(), + }, + { onConflict: "account_id" }, + ) + .select("*") + .single(); + + return { data, error }; +} diff --git a/lib/supabase/rooms/updateRoom.ts b/lib/supabase/rooms/updateRoom.ts new file mode 100644 index 00000000..20714531 --- /dev/null +++ b/lib/supabase/rooms/updateRoom.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; + +type Room = Tables<"rooms">; + +/** + * Updates a room's topic by ID. + * + * @param roomId - The ID of the room to update + * @param updates - The fields to update + * @returns The updated room data or null if not found or error + */ +export async function updateRoom( + roomId: string, + updates: TablesUpdate<"rooms">, +): Promise { + const { data, error } = await supabase + .from("rooms") + .update(updates) + .eq("id", roomId) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] updateRoom:", error); + return null; + } + + return data; +} diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts new file mode 100644 index 00000000..02a5f9af --- /dev/null +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getTaskRunHandler } from "../getTaskRunHandler"; +import { validateGetTaskRunQuery } from "../validateGetTaskRunQuery"; +import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; + +vi.mock("../validateGetTaskRunQuery", () => ({ + validateGetTaskRunQuery: vi.fn(), +})); + +vi.mock("@/lib/trigger/retrieveTaskRun", () => ({ + retrieveTaskRun: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +/** + * Creates a mock NextRequest for testing. + */ +function createMockRequest(): NextRequest { + return { + url: "http://localhost:3000/api/tasks/runs?runId=run_123", + headers: new Headers({ "x-api-key": "test-key" }), + nextUrl: new URL("http://localhost:3000/api/tasks/runs?runId=run_123"), + } as unknown as NextRequest; +} + +describe("getTaskRunHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns error response when validation fails (auth or query)", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(401); + }); + + it("returns error response when query validation fails", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "runId is required" }, { status: 400 }), + ); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(400); + }); + + it("returns pending status when task is still running", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_123" }); + vi.mocked(retrieveTaskRun).mockResolvedValue({ status: "pending" }); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ status: "pending" }); + }); + + it("returns complete status with data when task is completed", async () => { + const taskData = { result: "success", details: { foo: "bar" } }; + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_123" }); + vi.mocked(retrieveTaskRun).mockResolvedValue({ status: "complete", data: taskData }); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ status: "complete", data: taskData }); + }); + + it("returns failed status with error when task failed", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_123" }); + vi.mocked(retrieveTaskRun).mockResolvedValue({ + status: "failed", + error: "Task execution failed", + }); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ status: "failed", error: "Task execution failed" }); + }); + + it("calls retrieveTaskRun with the validated runId", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_specific_id" }); + vi.mocked(retrieveTaskRun).mockResolvedValue({ status: "pending" }); + + const request = createMockRequest(); + await getTaskRunHandler(request); + + expect(retrieveTaskRun).toHaveBeenCalledWith("run_specific_id"); + }); + + it("returns 500 error when retrieveTaskRun throws", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_123" }); + vi.mocked(retrieveTaskRun).mockRejectedValue(new Error("Trigger.dev API error")); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toBe("Trigger.dev API error"); + }); + + it("returns 404 when run is not found", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ runId: "run_nonexistent" }); + vi.mocked(retrieveTaskRun).mockResolvedValue(null); + + const request = createMockRequest(); + const response = await getTaskRunHandler(request); + + expect(response.status).toBe(404); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toContain("not found"); + }); +}); diff --git a/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts new file mode 100644 index 00000000..917e5d27 --- /dev/null +++ b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateGetTaskRunQuery } from "../validateGetTaskRunQuery"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +/** + * Creates a mock NextRequest with the given URL. + */ +function createMockRequest(url: string): NextRequest { + return { + url, + nextUrl: new URL(url), + headers: new Headers({ "x-api-key": "test-key" }), + } as unknown as NextRequest; +} + +describe("validateGetTaskRunQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("auth validation", () => { + it("returns 401 when auth validation fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = createMockRequest("http://localhost:3000/api/tasks/runs?runId=run_123"); + + const result = await validateGetTaskRunQuery(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); + + it("calls validateAuthContext with the request", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + const request = createMockRequest("http://localhost:3000/api/tasks/runs?runId=run_123"); + + await validateGetTaskRunQuery(request); + + expect(validateAuthContext).toHaveBeenCalledWith(request); + }); + }); + + describe("query validation", () => { + beforeEach(() => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + }); + + it("returns error when runId is missing", async () => { + const request = createMockRequest("http://localhost:3000/api/tasks/runs"); + + const result = await validateGetTaskRunQuery(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns error when runId is empty string", async () => { + const request = createMockRequest("http://localhost:3000/api/tasks/runs?runId="); + + const result = await validateGetTaskRunQuery(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns validated runId when provided", async () => { + const request = createMockRequest("http://localhost:3000/api/tasks/runs?runId=run_abc123"); + + const result = await validateGetTaskRunQuery(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ runId: "run_abc123" }); + }); + + it("trims whitespace from runId", async () => { + const request = createMockRequest( + "http://localhost:3000/api/tasks/runs?runId=%20run_abc123%20", + ); + + const result = await validateGetTaskRunQuery(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ runId: "run_abc123" }); + }); + + it("returns error response with proper error message", async () => { + const request = createMockRequest("http://localhost:3000/api/tasks/runs"); + + const result = await validateGetTaskRunQuery(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + const json = await result.json(); + expect(json.status).toBe("error"); + expect(json.error).toContain("runId"); + } + }); + }); +}); diff --git a/lib/tasks/getTaskRunHandler.ts b/lib/tasks/getTaskRunHandler.ts new file mode 100644 index 00000000..05ce6404 --- /dev/null +++ b/lib/tasks/getTaskRunHandler.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetTaskRunQuery } from "./validateGetTaskRunQuery"; +import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; + +/** + * Handles GET /api/tasks/runs requests. + * Retrieves the status of a Trigger.dev task run. + * + * @param request - The NextRequest object + * @returns A NextResponse with the task run status + */ +export async function getTaskRunHandler(request: NextRequest): Promise { + // Validate auth context and query parameters + const validatedQuery = await validateGetTaskRunQuery(request); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + try { + const result = await retrieveTaskRun(validatedQuery.runId); + + if (result === null) { + return NextResponse.json( + { + status: "error", + error: "Task run not found", + }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json(result, { + status: 200, + headers: getCorsHeaders(), + }); + } catch (error) { + console.error("Error retrieving task run:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/tasks/validateGetTaskRunQuery.ts b/lib/tasks/validateGetTaskRunQuery.ts new file mode 100644 index 00000000..223e292d --- /dev/null +++ b/lib/tasks/validateGetTaskRunQuery.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { z } from "zod"; + +const getTaskRunQuerySchema = z.object({ + runId: z + .string({ message: "runId is required" }) + .min(1, "runId is required") + .transform(val => val.trim()), +}); + +export type GetTaskRunQuery = z.infer; + +/** + * Validates auth context and query parameters for GET /api/tasks/runs. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export async function validateGetTaskRunQuery( + request: NextRequest, +): Promise { + // Validate auth context + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const runId = request.nextUrl.searchParams.get("runId") ?? ""; + + const result = getTaskRunQuerySchema.safeParse({ runId }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/trigger/__tests__/retrieveTaskRun.test.ts b/lib/trigger/__tests__/retrieveTaskRun.test.ts new file mode 100644 index 00000000..f61227c0 --- /dev/null +++ b/lib/trigger/__tests__/retrieveTaskRun.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runs } from "@trigger.dev/sdk/v3"; +import { retrieveTaskRun } from "../retrieveTaskRun"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + runs: { + retrieve: vi.fn(), + }, +})); + +describe("retrieveTaskRun", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls runs.retrieve with the provided runId", async () => { + const mockRun = { + id: "run_123", + status: "COMPLETED", + output: { result: "success" }, + }; + vi.mocked(runs.retrieve).mockResolvedValue(mockRun); + + await retrieveTaskRun("run_123"); + + expect(runs.retrieve).toHaveBeenCalledWith("run_123"); + }); + + it("returns pending status when run status is EXECUTING", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "EXECUTING", + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ status: "pending" }); + }); + + it("returns pending status when run status is QUEUED", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "QUEUED", + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ status: "pending" }); + }); + + it("returns pending status when run status is REATTEMPTING", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "REATTEMPTING", + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ status: "pending" }); + }); + + it("returns complete status with data when run status is COMPLETED", async () => { + const outputData = { message: "Task completed successfully" }; + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "COMPLETED", + output: outputData, + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ status: "complete", data: outputData }); + }); + + it("returns complete status with null data when output is undefined", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "COMPLETED", + output: undefined, + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ status: "complete", data: null }); + }); + + it("returns failed status with error when run status is FAILED", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "FAILED", + error: { message: "Task execution failed" }, + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ + status: "failed", + error: "Task execution failed", + }); + }); + + it("returns failed status with error when run status is CRASHED", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "CRASHED", + error: { message: "Task crashed unexpectedly" }, + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ + status: "failed", + error: "Task crashed unexpectedly", + }); + }); + + it("returns failed status with error when run status is CANCELED", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "CANCELED", + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ + status: "failed", + error: "Task was canceled", + }); + }); + + it("returns failed status with generic error when error message is not available", async () => { + vi.mocked(runs.retrieve).mockResolvedValue({ + id: "run_123", + status: "FAILED", + }); + + const result = await retrieveTaskRun("run_123"); + + expect(result).toEqual({ + status: "failed", + error: "Task execution failed", + }); + }); + + it("throws error when runs.retrieve fails", async () => { + vi.mocked(runs.retrieve).mockRejectedValue(new Error("API error")); + + await expect(retrieveTaskRun("run_123")).rejects.toThrow("API error"); + }); +}); diff --git a/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts b/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts new file mode 100644 index 00000000..64718f39 --- /dev/null +++ b/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { tasks } from "@trigger.dev/sdk"; +import { triggerRunSandboxCommand } from "../triggerRunSandboxCommand"; + +vi.mock("@trigger.dev/sdk", () => ({ + tasks: { + trigger: vi.fn(), + }, +})); + +describe("triggerRunSandboxCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("triggers run-sandbox-command task with correct payload", async () => { + const mockHandle = { id: "run_123" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle); + + const payload = { + command: "ls", + args: ["-la"], + cwd: "/home", + sandboxId: "sbx_456", + accountId: "acc_123", + }; + + const result = await triggerRunSandboxCommand(payload); + + expect(tasks.trigger).toHaveBeenCalledWith("run-sandbox-command", payload); + expect(result).toEqual(mockHandle); + }); + + it("passes through the task handle from trigger", async () => { + const mockHandle = { id: "run_789", publicAccessToken: "token_abc" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle); + + const result = await triggerRunSandboxCommand({ + command: "echo", + sandboxId: "sbx_999", + accountId: "acc_456", + }); + + expect(result).toBe(mockHandle); + }); +}); diff --git a/lib/trigger/retrieveTaskRun.ts b/lib/trigger/retrieveTaskRun.ts new file mode 100644 index 00000000..51fcbd32 --- /dev/null +++ b/lib/trigger/retrieveTaskRun.ts @@ -0,0 +1,54 @@ +import { runs } from "@trigger.dev/sdk/v3"; + +export type TaskRunResult = + | { status: "pending" } + | { status: "complete"; data: unknown } + | { status: "failed"; error: string }; + +const PENDING_STATUSES = ["EXECUTING", "QUEUED", "REATTEMPTING", "PENDING", "WAITING_FOR_DEPLOY"]; +const FAILED_STATUSES = [ + "FAILED", + "CRASHED", + "CANCELED", + "SYSTEM_FAILURE", + "INTERRUPTED", + "EXPIRED", + "TIMED_OUT", +]; + +/** + * Retrieves the status of a Trigger.dev task run. + * + * @param runId - The unique identifier of the task run + * @returns The task run result with status and data/error, or null if not found + */ +export async function retrieveTaskRun(runId: string): Promise { + const run = await runs.retrieve(runId); + + if (!run) { + return null; + } + + if (PENDING_STATUSES.includes(run.status)) { + return { status: "pending" }; + } + + if (run.status === "COMPLETED") { + return { status: "complete", data: run.output ?? null }; + } + + if (FAILED_STATUSES.includes(run.status)) { + let errorMessage = "Task execution failed"; + + if (run.status === "CANCELED") { + errorMessage = "Task was canceled"; + } else if (run.error && typeof run.error === "object" && "message" in run.error) { + errorMessage = (run.error as { message: string }).message; + } + + return { status: "failed", error: errorMessage }; + } + + // Unknown status, treat as pending + return { status: "pending" }; +} diff --git a/lib/trigger/triggerRunSandboxCommand.ts b/lib/trigger/triggerRunSandboxCommand.ts new file mode 100644 index 00000000..524f68ec --- /dev/null +++ b/lib/trigger/triggerRunSandboxCommand.ts @@ -0,0 +1,20 @@ +import { tasks } from "@trigger.dev/sdk"; + +type RunSandboxCommandPayload = { + command: string; + args?: string[]; + cwd?: string; + sandboxId: string; + accountId: string; +}; + +/** + * Triggers the run-sandbox-command task to execute a command in a sandbox. + * + * @param payload - The task payload with command, args, cwd, sandboxId, and accountId + * @returns The task handle with runId + */ +export async function triggerRunSandboxCommand(payload: RunSandboxCommandPayload) { + const handle = await tasks.trigger("run-sandbox-command", payload); + return handle; +} diff --git a/package.json b/package.json index dea03405..c53bb782 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "mcp-handler": "^1.0.4", "ms": "^2.1.3", "multiformats": "^13.4.1", - "next": "^16.0.7", + "next": "16.0.10", "node-telegram-bot-api": "^0.66.0", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6111b4..f0b0d314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ importers: version: 15.0.12 mcp-handler: specifier: ^1.0.4 - version: 1.0.4(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) + version: 1.0.4(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) ms: specifier: ^2.1.3 version: 2.1.3 @@ -87,8 +87,8 @@ importers: specifier: ^13.4.1 version: 13.4.1 next: - specifier: ^16.0.7 - version: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: 16.0.10 + version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) node-telegram-bot-api: specifier: ^0.66.0 version: 0.66.0(request@2.88.2) @@ -112,7 +112,7 @@ importers: version: 0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) x402-next: specifier: ^0.7.3 - version: 0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) zod: specifier: ^4.1.13 version: 4.1.13 @@ -1056,56 +1056,56 @@ packages: '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} - '@next/env@16.0.7': - resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} + '@next/env@16.0.10': + resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} '@next/eslint-plugin-next@15.1.7': resolution: {integrity: sha512-kRP7RjSxfTO13NE317ek3mSGzoZlI33nc/i5hs1KaWpK+egs85xg0DJ4p32QEiHnR0mVjuUfhRIun7awqfL7pQ==} - '@next/swc-darwin-arm64@16.0.7': - resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==} + '@next/swc-darwin-arm64@16.0.10': + resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.0.7': - resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} + '@next/swc-darwin-x64@16.0.10': + resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.0.7': - resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} + '@next/swc-linux-arm64-gnu@16.0.10': + resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.7': - resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} + '@next/swc-linux-arm64-musl@16.0.10': + resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.7': - resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} + '@next/swc-linux-x64-gnu@16.0.10': + resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.7': - resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} + '@next/swc-linux-x64-musl@16.0.10': + resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.0.7': - resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} + '@next/swc-win32-arm64-msvc@16.0.10': + resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.7': - resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} + '@next/swc-win32-x64-msvc@16.0.10': + resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2434,6 +2434,7 @@ packages: '@walletconnect/ethereum-provider@2.21.1': resolution: {integrity: sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/events@1.0.1': resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} @@ -2478,9 +2479,11 @@ packages: '@walletconnect/sign-client@2.21.0': resolution: {integrity: sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/sign-client@2.21.1': resolution: {integrity: sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/time@1.0.2': resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} @@ -2493,9 +2496,11 @@ packages: '@walletconnect/universal-provider@2.21.0': resolution: {integrity: sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/universal-provider@2.21.1': resolution: {integrity: sha512-Wjx9G8gUHVMnYfxtasC9poGm8QMiPCpXpbbLFT+iPoQskDDly8BwueWnqKs4Mx2SdIAWAwuXeZ5ojk5qQOxJJg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/utils@2.21.0': resolution: {integrity: sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==} @@ -4520,8 +4525,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@16.0.7: - resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} + next@16.0.10: + resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7150,34 +7155,34 @@ snapshots: '@next/env@14.2.35': {} - '@next/env@16.0.7': {} + '@next/env@16.0.10': {} '@next/eslint-plugin-next@15.1.7': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.0.7': + '@next/swc-darwin-arm64@16.0.10': optional: true - '@next/swc-darwin-x64@16.0.7': + '@next/swc-darwin-x64@16.0.10': optional: true - '@next/swc-linux-arm64-gnu@16.0.7': + '@next/swc-linux-arm64-gnu@16.0.10': optional: true - '@next/swc-linux-arm64-musl@16.0.7': + '@next/swc-linux-arm64-musl@16.0.10': optional: true - '@next/swc-linux-x64-gnu@16.0.7': + '@next/swc-linux-x64-gnu@16.0.10': optional: true - '@next/swc-linux-x64-musl@16.0.7': + '@next/swc-linux-x64-musl@16.0.10': optional: true - '@next/swc-win32-arm64-msvc@16.0.7': + '@next/swc-win32-arm64-msvc@16.0.10': optional: true - '@next/swc-win32-x64-msvc@16.0.7': + '@next/swc-win32-x64-msvc@16.0.10': optional: true '@noble/ciphers@1.2.1': {} @@ -11831,14 +11836,14 @@ snapshots: math-intrinsics@1.1.0: {} - mcp-handler@1.0.4(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)): + mcp-handler@1.0.4(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)): dependencies: '@modelcontextprotocol/sdk': 1.24.3(zod@4.1.13) chalk: 5.6.2 commander: 11.1.0 redis: 4.7.1 optionalDependencies: - next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) md5@2.3.0: dependencies: @@ -11981,9 +11986,9 @@ snapshots: negotiator@1.0.0: {} - next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@next/env': 16.0.7 + '@next/env': 16.0.10 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001757 postcss: 8.4.31 @@ -11991,14 +11996,14 @@ snapshots: react-dom: 19.2.1(react@19.2.1) styled-jsx: 5.1.6(react@19.2.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.7 - '@next/swc-darwin-x64': 16.0.7 - '@next/swc-linux-arm64-gnu': 16.0.7 - '@next/swc-linux-arm64-musl': 16.0.7 - '@next/swc-linux-x64-gnu': 16.0.7 - '@next/swc-linux-x64-musl': 16.0.7 - '@next/swc-win32-arm64-msvc': 16.0.7 - '@next/swc-win32-x64-msvc': 16.0.7 + '@next/swc-darwin-arm64': 16.0.10 + '@next/swc-darwin-x64': 16.0.10 + '@next/swc-linux-arm64-gnu': 16.0.10 + '@next/swc-linux-arm64-musl': 16.0.10 + '@next/swc-linux-x64-gnu': 16.0.10 + '@next/swc-linux-x64-musl': 16.0.10 + '@next/swc-win32-arm64-msvc': 16.0.10 + '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 sharp: 0.34.5 transitivePeerDependencies: @@ -13878,11 +13883,11 @@ snapshots: - utf-8-validate - ws - x402-next@0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + x402-next@0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: '@coinbase/cdp-sdk': 1.38.6(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) viem: 2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) x402: 0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) zod: 3.25.76 diff --git a/types/database.types.ts b/types/database.types.ts index 4aee9ca8..27d5db1a 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -1,3808 +1,3869 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "12.2.3 (519615d)"; - }; + PostgrestVersion: "12.2.3 (519615d)" + } public: { Tables: { account_api_keys: { Row: { - account: string | null; - created_at: string; - id: string; - key_hash: string | null; - last_used: string | null; - name: string; - }; - Insert: { - account?: string | null; - created_at?: string; - id?: string; - key_hash?: string | null; - last_used?: string | null; - name: string; - }; - Update: { - account?: string | null; - created_at?: string; - id?: string; - key_hash?: string | null; - last_used?: string | null; - name?: string; - }; + account: string | null + created_at: string + id: string + key_hash: string | null + last_used: string | null + name: string + } + Insert: { + account?: string | null + created_at?: string + id?: string + key_hash?: string | null + last_used?: string | null + name: string + } + Update: { + account?: string | null + created_at?: string + id?: string + key_hash?: string | null + last_used?: string | null + name?: string + } Relationships: [ { - foreignKeyName: "account_api_keys_account_fkey"; - columns: ["account"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_api_keys_account_fkey" + columns: ["account"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_artist_ids: { Row: { - account_id: string | null; - artist_id: string | null; - id: string; - pinned: boolean; - updated_at: string | null; - }; - Insert: { - account_id?: string | null; - artist_id?: string | null; - id?: string; - pinned?: boolean; - updated_at?: string | null; - }; - Update: { - account_id?: string | null; - artist_id?: string | null; - id?: string; - pinned?: boolean; - updated_at?: string | null; - }; + account_id: string | null + artist_id: string | null + id: string + pinned: boolean + updated_at: string | null + } + Insert: { + account_id?: string | null + artist_id?: string | null + id?: string + pinned?: boolean + updated_at?: string | null + } + Update: { + account_id?: string | null + artist_id?: string | null + id?: string + pinned?: boolean + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "account_artist_ids_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_artist_ids_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_artist_ids_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_artist_ids_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_catalogs: { Row: { - account: string; - catalog: string; - created_at: string; - id: string; - updated_at: string; - }; - Insert: { - account: string; - catalog: string; - created_at?: string; - id?: string; - updated_at?: string; - }; - Update: { - account?: string; - catalog?: string; - created_at?: string; - id?: string; - updated_at?: string; - }; + account: string + catalog: string + created_at: string + id: string + updated_at: string + } + Insert: { + account: string + catalog: string + created_at?: string + id?: string + updated_at?: string + } + Update: { + account?: string + catalog?: string + created_at?: string + id?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "account_catalogs_account_fkey"; - columns: ["account"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_catalogs_account_fkey" + columns: ["account"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_catalogs_catalog_fkey"; - columns: ["catalog"]; - isOneToOne: false; - referencedRelation: "catalogs"; - referencedColumns: ["id"]; + foreignKeyName: "account_catalogs_catalog_fkey" + columns: ["catalog"] + isOneToOne: false + referencedRelation: "catalogs" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_emails: { Row: { - account_id: string | null; - email: string | null; - id: string; - updated_at: string; - }; - Insert: { - account_id?: string | null; - email?: string | null; - id?: string; - updated_at?: string; - }; - Update: { - account_id?: string | null; - email?: string | null; - id?: string; - updated_at?: string; - }; + account_id: string | null + email: string | null + id: string + updated_at: string + } + Insert: { + account_id?: string | null + email?: string | null + id?: string + updated_at?: string + } + Update: { + account_id?: string | null + email?: string | null + id?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "account_emails_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_emails_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_info: { Row: { - account_id: string | null; - company_name: string | null; - id: string; - image: string | null; - instruction: string | null; - job_title: string | null; - knowledges: Json | null; - label: string | null; - onboarding_data: Json | null; - onboarding_status: Json | null; - organization: string | null; - role_type: string | null; - updated_at: string; - }; - Insert: { - account_id?: string | null; - company_name?: string | null; - id?: string; - image?: string | null; - instruction?: string | null; - job_title?: string | null; - knowledges?: Json | null; - label?: string | null; - onboarding_data?: Json | null; - onboarding_status?: Json | null; - organization?: string | null; - role_type?: string | null; - updated_at?: string; - }; - Update: { - account_id?: string | null; - company_name?: string | null; - id?: string; - image?: string | null; - instruction?: string | null; - job_title?: string | null; - knowledges?: Json | null; - label?: string | null; - onboarding_data?: Json | null; - onboarding_status?: Json | null; - organization?: string | null; - role_type?: string | null; - updated_at?: string; - }; + account_id: string | null + company_name: string | null + id: string + image: string | null + instruction: string | null + job_title: string | null + knowledges: Json | null + label: string | null + organization: string | null + role_type: string | null + updated_at: string + } + Insert: { + account_id?: string | null + company_name?: string | null + id?: string + image?: string | null + instruction?: string | null + job_title?: string | null + knowledges?: Json | null + label?: string | null + organization?: string | null + role_type?: string | null + updated_at?: string + } + Update: { + account_id?: string | null + company_name?: string | null + id?: string + image?: string | null + instruction?: string | null + job_title?: string | null + knowledges?: Json | null + label?: string | null + organization?: string | null + role_type?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "account_info_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_info_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_organization_ids: { Row: { - account_id: string | null; - id: string; - organization_id: string | null; - updated_at: string | null; - }; - Insert: { - account_id?: string | null; - id?: string; - organization_id?: string | null; - updated_at?: string | null; - }; - Update: { - account_id?: string | null; - id?: string; - organization_id?: string | null; - updated_at?: string | null; - }; + account_id: string | null + id: string + organization_id: string | null + updated_at: string | null + } + Insert: { + account_id?: string | null + id?: string + organization_id?: string | null + updated_at?: string | null + } + Update: { + account_id?: string | null + id?: string + organization_id?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "account_organization_ids_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_organization_ids_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_organization_ids_organization_id_fkey"; - columns: ["organization_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_organization_ids_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_phone_numbers: { Row: { - account_id: string; - id: string; - phone_number: string; - updated_at: string | null; - }; - Insert: { - account_id: string; - id?: string; - phone_number: string; - updated_at?: string | null; - }; - Update: { - account_id?: string; - id?: string; - phone_number?: string; - updated_at?: string | null; - }; + account_id: string + id: string + phone_number: string + updated_at: string | null + } + Insert: { + account_id: string + id?: string + phone_number: string + updated_at?: string | null + } + Update: { + account_id?: string + id?: string + phone_number?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "account_phone_numbers_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + ] + } + account_sandboxes: { + Row: { + account_id: string + created_at: string + id: string + sandbox_id: string + } + Insert: { + account_id: string + created_at?: string + id?: string + sandbox_id: string + } + Update: { + account_id?: string + created_at?: string + id?: string + sandbox_id?: string + } Relationships: [ { - foreignKeyName: "account_phone_numbers_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_sandboxes_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } + account_snapshots: { + Row: { + account_id: string + created_at: string | null + expires_at: string + github_repo: string | null + snapshot_id: string + } + Insert: { + account_id: string + created_at?: string | null + expires_at: string + github_repo?: string | null + snapshot_id: string + } + Update: { + account_id?: string + created_at?: string | null + expires_at?: string + github_repo?: string | null + snapshot_id?: string + } + Relationships: [ + { + foreignKeyName: "account_snapshots_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + ] + } account_socials: { Row: { - account_id: string | null; - id: string; - social_id: string; - }; + account_id: string | null + id: string + social_id: string + } Insert: { - account_id?: string | null; - id?: string; - social_id?: string; - }; + account_id?: string | null + id?: string + social_id?: string + } Update: { - account_id?: string | null; - id?: string; - social_id?: string; - }; + account_id?: string | null + id?: string + social_id?: string + } Relationships: [ { - foreignKeyName: "account_socials_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_socials_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_socials_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "account_socials_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_wallets: { Row: { - account_id: string; - id: string; - updated_at: string | null; - wallet: string; - }; - Insert: { - account_id: string; - id?: string; - updated_at?: string | null; - wallet: string; - }; - Update: { - account_id?: string; - id?: string; - updated_at?: string | null; - wallet?: string; - }; + account_id: string + id: string + updated_at: string | null + wallet: string + } + Insert: { + account_id: string + id?: string + updated_at?: string | null + wallet: string + } + Update: { + account_id?: string + id?: string + updated_at?: string | null + wallet?: string + } Relationships: [ { - foreignKeyName: "account_wallets_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_wallets_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } account_workspace_ids: { Row: { - account_id: string | null; - id: string; - updated_at: string | null; - workspace_id: string | null; - }; - Insert: { - account_id?: string | null; - id?: string; - updated_at?: string | null; - workspace_id?: string | null; - }; - Update: { - account_id?: string | null; - id?: string; - updated_at?: string | null; - workspace_id?: string | null; - }; + account_id: string | null + id: string + updated_at: string | null + workspace_id: string | null + } + Insert: { + account_id?: string | null + id?: string + updated_at?: string | null + workspace_id?: string | null + } + Update: { + account_id?: string | null + id?: string + updated_at?: string | null + workspace_id?: string | null + } Relationships: [ { - foreignKeyName: "account_workspace_ids_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_workspace_ids_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_workspace_ids_workspace_id_fkey"; - columns: ["workspace_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_workspace_ids_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } accounts: { Row: { - id: string; - name: string | null; - timestamp: number | null; - }; - Insert: { - id?: string; - name?: string | null; - timestamp?: number | null; - }; - Update: { - id?: string; - name?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + id: string + name: string | null + timestamp: number | null + } + Insert: { + id?: string + name?: string | null + timestamp?: number | null + } + Update: { + id?: string + name?: string | null + timestamp?: number | null + } + Relationships: [] + } accounts_memberships: { Row: { - account_id: string; - account_role: string; - created_at: string; - created_by: string | null; - updated_at: string; - updated_by: string | null; - user_id: string; - }; - Insert: { - account_id: string; - account_role: string; - created_at?: string; - created_by?: string | null; - updated_at?: string; - updated_by?: string | null; - user_id: string; - }; - Update: { - account_id?: string; - account_role?: string; - created_at?: string; - created_by?: string | null; - updated_at?: string; - updated_by?: string | null; - user_id?: string; - }; + account_id: string + account_role: string + created_at: string + created_by: string | null + updated_at: string + updated_by: string | null + user_id: string + } + Insert: { + account_id: string + account_role: string + created_at?: string + created_by?: string | null + updated_at?: string + updated_by?: string | null + user_id: string + } + Update: { + account_id?: string + account_role?: string + created_at?: string + created_by?: string | null + updated_at?: string + updated_by?: string | null + user_id?: string + } Relationships: [ { - foreignKeyName: "accounts_memberships_account_role_fkey"; - columns: ["account_role"]; - isOneToOne: false; - referencedRelation: "roles"; - referencedColumns: ["name"]; + foreignKeyName: "accounts_memberships_account_role_fkey" + columns: ["account_role"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["name"] }, - ]; - }; + ] + } admin_expenses: { Row: { - amount: number; - category: string; - created_at: string | null; - created_by: string | null; - id: string; - is_active: boolean | null; - item_name: string; - updated_at: string | null; - }; - Insert: { - amount?: number; - category: string; - created_at?: string | null; - created_by?: string | null; - id?: string; - is_active?: boolean | null; - item_name: string; - updated_at?: string | null; - }; - Update: { - amount?: number; - category?: string; - created_at?: string | null; - created_by?: string | null; - id?: string; - is_active?: boolean | null; - item_name?: string; - updated_at?: string | null; - }; - Relationships: []; - }; + amount: number + category: string + created_at: string | null + created_by: string | null + id: string + is_active: boolean | null + item_name: string + updated_at: string | null + } + Insert: { + amount?: number + category: string + created_at?: string | null + created_by?: string | null + id?: string + is_active?: boolean | null + item_name: string + updated_at?: string | null + } + Update: { + amount?: number + category?: string + created_at?: string | null + created_by?: string | null + id?: string + is_active?: boolean | null + item_name?: string + updated_at?: string | null + } + Relationships: [] + } admin_user_profiles: { Row: { - company: string | null; - context_notes: string | null; - created_at: string | null; - email: string; - id: string; - job_title: string | null; - last_contact_date: string | null; - meeting_notes: string | null; - observations: string | null; - opportunities: string | null; - pain_points: string | null; - sentiment: string | null; - tags: string[] | null; - updated_at: string | null; - }; - Insert: { - company?: string | null; - context_notes?: string | null; - created_at?: string | null; - email: string; - id?: string; - job_title?: string | null; - last_contact_date?: string | null; - meeting_notes?: string | null; - observations?: string | null; - opportunities?: string | null; - pain_points?: string | null; - sentiment?: string | null; - tags?: string[] | null; - updated_at?: string | null; - }; - Update: { - company?: string | null; - context_notes?: string | null; - created_at?: string | null; - email?: string; - id?: string; - job_title?: string | null; - last_contact_date?: string | null; - meeting_notes?: string | null; - observations?: string | null; - opportunities?: string | null; - pain_points?: string | null; - sentiment?: string | null; - tags?: string[] | null; - updated_at?: string | null; - }; - Relationships: []; - }; + company: string | null + context_notes: string | null + created_at: string | null + email: string + id: string + job_title: string | null + last_contact_date: string | null + meeting_notes: string | null + observations: string | null + opportunities: string | null + pain_points: string | null + sentiment: string | null + tags: string[] | null + updated_at: string | null + } + Insert: { + company?: string | null + context_notes?: string | null + created_at?: string | null + email: string + id?: string + job_title?: string | null + last_contact_date?: string | null + meeting_notes?: string | null + observations?: string | null + opportunities?: string | null + pain_points?: string | null + sentiment?: string | null + tags?: string[] | null + updated_at?: string | null + } + Update: { + company?: string | null + context_notes?: string | null + created_at?: string | null + email?: string + id?: string + job_title?: string | null + last_contact_date?: string | null + meeting_notes?: string | null + observations?: string | null + opportunities?: string | null + pain_points?: string | null + sentiment?: string | null + tags?: string[] | null + updated_at?: string | null + } + Relationships: [] + } agent_status: { Row: { - agent_id: string; - id: string; - progress: number | null; - social_id: string; - status: number | null; - updated_at: string; - }; - Insert: { - agent_id?: string; - id?: string; - progress?: number | null; - social_id: string; - status?: number | null; - updated_at?: string; - }; - Update: { - agent_id?: string; - id?: string; - progress?: number | null; - social_id?: string; - status?: number | null; - updated_at?: string; - }; + agent_id: string + id: string + progress: number | null + social_id: string + status: number | null + updated_at: string + } + Insert: { + agent_id?: string + id?: string + progress?: number | null + social_id: string + status?: number | null + updated_at?: string + } + Update: { + agent_id?: string + id?: string + progress?: number | null + social_id?: string + status?: number | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "agent_status_agent_id_fkey"; - columns: ["agent_id"]; - isOneToOne: false; - referencedRelation: "agents"; - referencedColumns: ["id"]; + foreignKeyName: "agent_status_agent_id_fkey" + columns: ["agent_id"] + isOneToOne: false + referencedRelation: "agents" + referencedColumns: ["id"] }, { - foreignKeyName: "agent_status_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "agent_status_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } agent_template_favorites: { Row: { - created_at: string | null; - template_id: string; - user_id: string; - }; + created_at: string | null + template_id: string + user_id: string + } Insert: { - created_at?: string | null; - template_id: string; - user_id: string; - }; + created_at?: string | null + template_id: string + user_id: string + } Update: { - created_at?: string | null; - template_id?: string; - user_id?: string; - }; + created_at?: string | null + template_id?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "agent_template_favorites_template_id_fkey"; - columns: ["template_id"]; - isOneToOne: false; - referencedRelation: "agent_templates"; - referencedColumns: ["id"]; + foreignKeyName: "agent_template_favorites_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "agent_templates" + referencedColumns: ["id"] }, { - foreignKeyName: "agent_template_favorites_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "agent_template_favorites_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } agent_template_shares: { Row: { - created_at: string | null; - template_id: string; - user_id: string; - }; + created_at: string | null + template_id: string + user_id: string + } Insert: { - created_at?: string | null; - template_id: string; - user_id: string; - }; + created_at?: string | null + template_id: string + user_id: string + } Update: { - created_at?: string | null; - template_id?: string; - user_id?: string; - }; + created_at?: string | null + template_id?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "agent_template_shares_template_id_fkey"; - columns: ["template_id"]; - isOneToOne: false; - referencedRelation: "agent_templates"; - referencedColumns: ["id"]; + foreignKeyName: "agent_template_shares_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "agent_templates" + referencedColumns: ["id"] }, { - foreignKeyName: "agent_template_shares_user_id_fkey"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "agent_template_shares_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } agent_templates: { Row: { - created_at: string; - creator: string | null; - description: string; - favorites_count: number; - id: string; - is_private: boolean; - prompt: string; - tags: string[]; - title: string; - updated_at: string | null; - }; - Insert: { - created_at?: string; - creator?: string | null; - description: string; - favorites_count?: number; - id?: string; - is_private?: boolean; - prompt: string; - tags?: string[]; - title: string; - updated_at?: string | null; - }; - Update: { - created_at?: string; - creator?: string | null; - description?: string; - favorites_count?: number; - id?: string; - is_private?: boolean; - prompt?: string; - tags?: string[]; - title?: string; - updated_at?: string | null; - }; + created_at: string + creator: string | null + description: string + favorites_count: number + id: string + is_private: boolean + prompt: string + tags: string[] + title: string + updated_at: string | null + } + Insert: { + created_at?: string + creator?: string | null + description: string + favorites_count?: number + id?: string + is_private?: boolean + prompt: string + tags?: string[] + title: string + updated_at?: string | null + } + Update: { + created_at?: string + creator?: string | null + description?: string + favorites_count?: number + id?: string + is_private?: boolean + prompt?: string + tags?: string[] + title?: string + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "agent_templates_creator_fkey"; - columns: ["creator"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "agent_templates_creator_fkey" + columns: ["creator"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } agents: { Row: { - id: string; - updated_at: string; - }; + id: string + updated_at: string + } Insert: { - id?: string; - updated_at?: string; - }; + id?: string + updated_at?: string + } Update: { - id?: string; - updated_at?: string; - }; - Relationships: []; - }; + id?: string + updated_at?: string + } + Relationships: [] + } app_store_link_clicked: { Row: { - clientId: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - clientId?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - clientId?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + clientId: string | null + id: string | null + timestamp: number | null + } + Insert: { + clientId?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + clientId?: string | null + id?: string | null + timestamp?: number | null + } + Relationships: [] + } apple_login_button_clicked: { Row: { - campaignId: string | null; - clientId: string | null; - fanId: string | null; - game: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; + campaignId: string | null + clientId: string | null + fanId: string | null + game: string | null + id: string | null + timestamp: number | null + } + Insert: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: number | null + } Relationships: [ { - foreignKeyName: "apple_login_button_clicked_campaignId_fkey"; - columns: ["campaignId"]; - isOneToOne: false; - referencedRelation: "campaigns"; - referencedColumns: ["id"]; + foreignKeyName: "apple_login_button_clicked_campaignId_fkey" + columns: ["campaignId"] + isOneToOne: false + referencedRelation: "campaigns" + referencedColumns: ["id"] }, - ]; - }; + ] + } apple_music: { Row: { - fanId: string | null; - game: string | null; - id: string | null; - syncid: string | null; - syncId: string | null; - timestamp: number | null; - }; - Insert: { - fanId?: string | null; - game?: string | null; - id?: string | null; - syncid?: string | null; - syncId?: string | null; - timestamp?: number | null; - }; - Update: { - fanId?: string | null; - game?: string | null; - id?: string | null; - syncid?: string | null; - syncId?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + fanId: string | null + game: string | null + id: string | null + syncid: string | null + syncId: string | null + timestamp: number | null + } + Insert: { + fanId?: string | null + game?: string | null + id?: string | null + syncid?: string | null + syncId?: string | null + timestamp?: number | null + } + Update: { + fanId?: string | null + game?: string | null + id?: string | null + syncid?: string | null + syncId?: string | null + timestamp?: number | null + } + Relationships: [] + } apple_play_button_clicked: { Row: { - appleId: string | null; - campaignId: string | null; - clientId: string | null; - fanId: string | null; - game: string | null; - id: string; - timestamp: number | null; - }; - Insert: { - appleId?: string | null; - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string; - timestamp?: number | null; - }; - Update: { - appleId?: string | null; - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string; - timestamp?: number | null; - }; + appleId: string | null + campaignId: string | null + clientId: string | null + fanId: string | null + game: string | null + id: string + timestamp: number | null + } + Insert: { + appleId?: string | null + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string + timestamp?: number | null + } + Update: { + appleId?: string | null + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string + timestamp?: number | null + } Relationships: [ { - foreignKeyName: "apple_play_button_clicked_campaignId_fkey"; - columns: ["campaignId"]; - isOneToOne: false; - referencedRelation: "campaigns"; - referencedColumns: ["id"]; + foreignKeyName: "apple_play_button_clicked_campaignId_fkey" + columns: ["campaignId"] + isOneToOne: false + referencedRelation: "campaigns" + referencedColumns: ["id"] }, - ]; - }; + ] + } artist_fan_segment: { Row: { - artist_social_id: string | null; - fan_social_id: string | null; - id: string; - segment_name: string | null; - updated_at: string; - }; - Insert: { - artist_social_id?: string | null; - fan_social_id?: string | null; - id?: string; - segment_name?: string | null; - updated_at?: string; - }; - Update: { - artist_social_id?: string | null; - fan_social_id?: string | null; - id?: string; - segment_name?: string | null; - updated_at?: string; - }; + artist_social_id: string | null + fan_social_id: string | null + id: string + segment_name: string | null + updated_at: string + } + Insert: { + artist_social_id?: string | null + fan_social_id?: string | null + id?: string + segment_name?: string | null + updated_at?: string + } + Update: { + artist_social_id?: string | null + fan_social_id?: string | null + id?: string + segment_name?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "artist_fan_segment_artist_social_id_fkey"; - columns: ["artist_social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "artist_fan_segment_artist_social_id_fkey" + columns: ["artist_social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, { - foreignKeyName: "artist_fan_segment_fan_social_id_fkey"; - columns: ["fan_social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "artist_fan_segment_fan_social_id_fkey" + columns: ["fan_social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } artist_organization_ids: { Row: { - artist_id: string; - created_at: string | null; - id: string; - organization_id: string; - updated_at: string | null; - }; - Insert: { - artist_id: string; - created_at?: string | null; - id?: string; - organization_id: string; - updated_at?: string | null; - }; - Update: { - artist_id?: string; - created_at?: string | null; - id?: string; - organization_id?: string; - updated_at?: string | null; - }; + artist_id: string + created_at: string | null + id: string + organization_id: string + updated_at: string | null + } + Insert: { + artist_id: string + created_at?: string | null + id?: string + organization_id: string + updated_at?: string | null + } + Update: { + artist_id?: string + created_at?: string | null + id?: string + organization_id?: string + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "artist_organization_ids_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "artist_organization_ids_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "artist_organization_ids_organization_id_fkey"; - columns: ["organization_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "artist_organization_ids_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } artist_segments: { Row: { - artist_account_id: string; - id: string; - segment_id: string; - updated_at: string | null; - }; - Insert: { - artist_account_id: string; - id?: string; - segment_id: string; - updated_at?: string | null; - }; - Update: { - artist_account_id?: string; - id?: string; - segment_id?: string; - updated_at?: string | null; - }; + artist_account_id: string + id: string + segment_id: string + updated_at: string | null + } + Insert: { + artist_account_id: string + id?: string + segment_id: string + updated_at?: string | null + } + Update: { + artist_account_id?: string + id?: string + segment_id?: string + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "artist_segments_artist_account_id_fkey"; - columns: ["artist_account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "artist_segments_artist_account_id_fkey" + columns: ["artist_account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "artist_segments_segment_id_fkey"; - columns: ["segment_id"]; - isOneToOne: false; - referencedRelation: "segments"; - referencedColumns: ["id"]; + foreignKeyName: "artist_segments_segment_id_fkey" + columns: ["segment_id"] + isOneToOne: false + referencedRelation: "segments" + referencedColumns: ["id"] }, - ]; - }; + ] + } billing_customers: { Row: { - account_id: string; - customer_id: string; - email: string | null; - id: number; - provider: Database["public"]["Enums"]["billing_provider"]; - }; - Insert: { - account_id: string; - customer_id: string; - email?: string | null; - id?: number; - provider: Database["public"]["Enums"]["billing_provider"]; - }; - Update: { - account_id?: string; - customer_id?: string; - email?: string | null; - id?: number; - provider?: Database["public"]["Enums"]["billing_provider"]; - }; - Relationships: []; - }; + account_id: string + customer_id: string + email: string | null + id: number + provider: Database["public"]["Enums"]["billing_provider"] + } + Insert: { + account_id: string + customer_id: string + email?: string | null + id?: number + provider: Database["public"]["Enums"]["billing_provider"] + } + Update: { + account_id?: string + customer_id?: string + email?: string | null + id?: number + provider?: Database["public"]["Enums"]["billing_provider"] + } + Relationships: [] + } campaigns: { Row: { - artist_id: string | null; - clientId: string | null; - id: string; - timestamp: number | null; - }; - Insert: { - artist_id?: string | null; - clientId?: string | null; - id?: string; - timestamp?: number | null; - }; - Update: { - artist_id?: string | null; - clientId?: string | null; - id?: string; - timestamp?: number | null; - }; + artist_id: string | null + clientId: string | null + id: string + timestamp: number | null + } + Insert: { + artist_id?: string | null + clientId?: string | null + id?: string + timestamp?: number | null + } + Update: { + artist_id?: string | null + clientId?: string | null + id?: string + timestamp?: number | null + } Relationships: [ { - foreignKeyName: "campaigns_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "campaigns_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } catalog_songs: { Row: { - catalog: string; - created_at: string; - id: string; - song: string; - updated_at: string; - }; - Insert: { - catalog: string; - created_at?: string; - id?: string; - song: string; - updated_at?: string; - }; - Update: { - catalog?: string; - created_at?: string; - id?: string; - song?: string; - updated_at?: string; - }; + catalog: string + created_at: string + id: string + song: string + updated_at: string + } + Insert: { + catalog: string + created_at?: string + id?: string + song: string + updated_at?: string + } + Update: { + catalog?: string + created_at?: string + id?: string + song?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "catalog_songs_catalog_fkey"; - columns: ["catalog"]; - isOneToOne: false; - referencedRelation: "catalogs"; - referencedColumns: ["id"]; + foreignKeyName: "catalog_songs_catalog_fkey" + columns: ["catalog"] + isOneToOne: false + referencedRelation: "catalogs" + referencedColumns: ["id"] }, { - foreignKeyName: "catalog_songs_song_fkey"; - columns: ["song"]; - isOneToOne: false; - referencedRelation: "songs"; - referencedColumns: ["isrc"]; + foreignKeyName: "catalog_songs_song_fkey" + columns: ["song"] + isOneToOne: false + referencedRelation: "songs" + referencedColumns: ["isrc"] }, - ]; - }; + ] + } catalogs: { Row: { - created_at: string; - id: string; - name: string; - updated_at: string; - }; - Insert: { - created_at?: string; - id?: string; - name: string; - updated_at?: string; - }; - Update: { - created_at?: string; - id?: string; - name?: string; - updated_at?: string; - }; - Relationships: []; - }; + created_at: string + id: string + name: string + updated_at: string + } + Insert: { + created_at?: string + id?: string + name: string + updated_at?: string + } + Update: { + created_at?: string + id?: string + name?: string + updated_at?: string + } + Relationships: [] + } config: { Row: { - billing_provider: Database["public"]["Enums"]["billing_provider"]; - enable_account_billing: boolean; - enable_team_account_billing: boolean; - enable_team_accounts: boolean; - }; - Insert: { - billing_provider?: Database["public"]["Enums"]["billing_provider"]; - enable_account_billing?: boolean; - enable_team_account_billing?: boolean; - enable_team_accounts?: boolean; - }; - Update: { - billing_provider?: Database["public"]["Enums"]["billing_provider"]; - enable_account_billing?: boolean; - enable_team_account_billing?: boolean; - enable_team_accounts?: boolean; - }; - Relationships: []; - }; + billing_provider: Database["public"]["Enums"]["billing_provider"] + enable_account_billing: boolean + enable_team_account_billing: boolean + enable_team_accounts: boolean + } + Insert: { + billing_provider?: Database["public"]["Enums"]["billing_provider"] + enable_account_billing?: boolean + enable_team_account_billing?: boolean + enable_team_accounts?: boolean + } + Update: { + billing_provider?: Database["public"]["Enums"]["billing_provider"] + enable_account_billing?: boolean + enable_team_account_billing?: boolean + enable_team_accounts?: boolean + } + Relationships: [] + } cookie_players: { Row: { - game: string | null; - id: string | null; - timestamp: number | null; - uniquePlayerID: string | null; - }; - Insert: { - game?: string | null; - id?: string | null; - timestamp?: number | null; - uniquePlayerID?: string | null; - }; - Update: { - game?: string | null; - id?: string | null; - timestamp?: number | null; - uniquePlayerID?: string | null; - }; - Relationships: []; - }; + game: string | null + id: string | null + timestamp: number | null + uniquePlayerID: string | null + } + Insert: { + game?: string | null + id?: string | null + timestamp?: number | null + uniquePlayerID?: string | null + } + Update: { + game?: string | null + id?: string | null + timestamp?: number | null + uniquePlayerID?: string | null + } + Relationships: [] + } credits_usage: { Row: { - account_id: string; - id: number; - remaining_credits: number; - timestamp: string | null; - }; - Insert: { - account_id: string; - id?: number; - remaining_credits?: number; - timestamp?: string | null; - }; - Update: { - account_id?: string; - id?: number; - remaining_credits?: number; - timestamp?: string | null; - }; + account_id: string + id: number + remaining_credits: number + timestamp: string | null + } + Insert: { + account_id: string + id?: number + remaining_credits?: number + timestamp?: string | null + } + Update: { + account_id?: string + id?: number + remaining_credits?: number + timestamp?: string | null + } Relationships: [ { - foreignKeyName: "credits_usage_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "credits_usage_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } cta_redirect: { Row: { - clientId: string; - id: number; - timestamp: string | null; - url: string | null; - }; - Insert: { - clientId: string; - id?: number; - timestamp?: string | null; - url?: string | null; - }; - Update: { - clientId?: string; - id?: number; - timestamp?: string | null; - url?: string | null; - }; - Relationships: []; - }; + clientId: string + id: number + timestamp: string | null + url: string | null + } + Insert: { + clientId: string + id?: number + timestamp?: string | null + url?: string | null + } + Update: { + clientId?: string + id?: number + timestamp?: string | null + url?: string | null + } + Relationships: [] + } error_logs: { Row: { - account_id: string | null; - created_at: string; - error_message: string | null; - error_timestamp: string | null; - error_type: string | null; - id: string; - last_message: string | null; - raw_message: string; - room_id: string | null; - stack_trace: string | null; - telegram_message_id: number | null; - tool_name: string | null; - }; - Insert: { - account_id?: string | null; - created_at?: string; - error_message?: string | null; - error_timestamp?: string | null; - error_type?: string | null; - id?: string; - last_message?: string | null; - raw_message: string; - room_id?: string | null; - stack_trace?: string | null; - telegram_message_id?: number | null; - tool_name?: string | null; - }; - Update: { - account_id?: string | null; - created_at?: string; - error_message?: string | null; - error_timestamp?: string | null; - error_type?: string | null; - id?: string; - last_message?: string | null; - raw_message?: string; - room_id?: string | null; - stack_trace?: string | null; - telegram_message_id?: number | null; - tool_name?: string | null; - }; + account_id: string | null + created_at: string + error_message: string | null + error_timestamp: string | null + error_type: string | null + id: string + last_message: string | null + raw_message: string + room_id: string | null + stack_trace: string | null + telegram_message_id: number | null + tool_name: string | null + } + Insert: { + account_id?: string | null + created_at?: string + error_message?: string | null + error_timestamp?: string | null + error_type?: string | null + id?: string + last_message?: string | null + raw_message: string + room_id?: string | null + stack_trace?: string | null + telegram_message_id?: number | null + tool_name?: string | null + } + Update: { + account_id?: string | null + created_at?: string + error_message?: string | null + error_timestamp?: string | null + error_type?: string | null + id?: string + last_message?: string | null + raw_message?: string + room_id?: string | null + stack_trace?: string | null + telegram_message_id?: number | null + tool_name?: string | null + } Relationships: [ { - foreignKeyName: "error_logs_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "error_logs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "error_logs_room_id_fkey"; - columns: ["room_id"]; - isOneToOne: false; - referencedRelation: "rooms"; - referencedColumns: ["id"]; + foreignKeyName: "error_logs_room_id_fkey" + columns: ["room_id"] + isOneToOne: false + referencedRelation: "rooms" + referencedColumns: ["id"] }, - ]; - }; + ] + } fan_segments: { Row: { - fan_social_id: string; - id: string; - segment_id: string; - updated_at: string | null; - }; - Insert: { - fan_social_id: string; - id?: string; - segment_id: string; - updated_at?: string | null; - }; - Update: { - fan_social_id?: string; - id?: string; - segment_id?: string; - updated_at?: string | null; - }; + fan_social_id: string + id: string + segment_id: string + updated_at: string | null + } + Insert: { + fan_social_id: string + id?: string + segment_id: string + updated_at?: string | null + } + Update: { + fan_social_id?: string + id?: string + segment_id?: string + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "fan_segments_fan_social_id_fkey"; - columns: ["fan_social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "fan_segments_fan_social_id_fkey" + columns: ["fan_social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, { - foreignKeyName: "fan_segments_segment_id_fkey"; - columns: ["segment_id"]; - isOneToOne: false; - referencedRelation: "segments"; - referencedColumns: ["id"]; + foreignKeyName: "fan_segments_segment_id_fkey" + columns: ["segment_id"] + isOneToOne: false + referencedRelation: "segments" + referencedColumns: ["id"] }, - ]; - }; + ] + } fans: { Row: { - account_status: string | null; - apple_token: string | null; - campaign_id: string | null; - campaign_interaction_count: number | null; - campaignId: string | null; - city: string | null; - click_through_rate: number | null; - clientId: string | null; - consent_given: boolean | null; - country: string | null; - custom_tags: Json | null; - discord_username: string | null; - display_name: string | null; - email: string | null; - email_open_rate: number | null; - engagement_level: string | null; - episodes: Json | null; - explicit_content_filter_enabled: boolean | null; - explicit_content_filter_locked: boolean | null; - "explicit_content.filter_enabled": boolean | null; - "explicit_content.filter_locked": boolean | null; - external_urls_spotify: string | null; - "external_urls.spotify": string | null; - facebook_profile_url: string | null; - first_stream_date: string | null; - followedArtists: Json | null; - followers_total: number | null; - "followers.href": string | null; - "followers.total": number | null; - gamification_points: number | null; - genres: Json | null; - heavyRotations: Json | null; - href: string | null; - id: string; - images: Json | null; - instagram_handle: string | null; - last_campaign_interaction: string | null; - last_login: string | null; - last_purchase_date: string | null; - last_stream_date: string | null; - linkedin_profile_url: string | null; - os_type: string | null; - playlist: Json | null; - preferences: Json | null; - preferred_artists: Json | null; - preferred_device: string | null; - product: string | null; - recentlyPlayed: Json | null; - recommendations: Json | null; - recommended_events: Json | null; - reddit_username: string | null; - saved_podcasts: Json | null; - savedAlbums: Json | null; - savedAudioBooks: Json | null; - savedShows: Json | null; - savedTracks: Json | null; - social_shares: number | null; - spotify_token: string | null; - subscription_tier: string | null; - testField: string | null; - tiktok_handle: string | null; - time_zone: string | null; - timestamp: string | null; - top_artists_long_term: Json | null; - top_artists_medium_term: Json | null; - top_tracks_long_term: Json | null; - top_tracks_medium_term: Json | null; - top_tracks_short_term: Json | null; - topArtists: Json | null; - topTracks: Json | null; - total_spent: number | null; - total_streams: number | null; - twitter_handle: string | null; - type: string | null; - uri: string | null; - youtube_channel_url: string | null; - }; - Insert: { - account_status?: string | null; - apple_token?: string | null; - campaign_id?: string | null; - campaign_interaction_count?: number | null; - campaignId?: string | null; - city?: string | null; - click_through_rate?: number | null; - clientId?: string | null; - consent_given?: boolean | null; - country?: string | null; - custom_tags?: Json | null; - discord_username?: string | null; - display_name?: string | null; - email?: string | null; - email_open_rate?: number | null; - engagement_level?: string | null; - episodes?: Json | null; - explicit_content_filter_enabled?: boolean | null; - explicit_content_filter_locked?: boolean | null; - "explicit_content.filter_enabled"?: boolean | null; - "explicit_content.filter_locked"?: boolean | null; - external_urls_spotify?: string | null; - "external_urls.spotify"?: string | null; - facebook_profile_url?: string | null; - first_stream_date?: string | null; - followedArtists?: Json | null; - followers_total?: number | null; - "followers.href"?: string | null; - "followers.total"?: number | null; - gamification_points?: number | null; - genres?: Json | null; - heavyRotations?: Json | null; - href?: string | null; - id?: string; - images?: Json | null; - instagram_handle?: string | null; - last_campaign_interaction?: string | null; - last_login?: string | null; - last_purchase_date?: string | null; - last_stream_date?: string | null; - linkedin_profile_url?: string | null; - os_type?: string | null; - playlist?: Json | null; - preferences?: Json | null; - preferred_artists?: Json | null; - preferred_device?: string | null; - product?: string | null; - recentlyPlayed?: Json | null; - recommendations?: Json | null; - recommended_events?: Json | null; - reddit_username?: string | null; - saved_podcasts?: Json | null; - savedAlbums?: Json | null; - savedAudioBooks?: Json | null; - savedShows?: Json | null; - savedTracks?: Json | null; - social_shares?: number | null; - spotify_token?: string | null; - subscription_tier?: string | null; - testField?: string | null; - tiktok_handle?: string | null; - time_zone?: string | null; - timestamp?: string | null; - top_artists_long_term?: Json | null; - top_artists_medium_term?: Json | null; - top_tracks_long_term?: Json | null; - top_tracks_medium_term?: Json | null; - top_tracks_short_term?: Json | null; - topArtists?: Json | null; - topTracks?: Json | null; - total_spent?: number | null; - total_streams?: number | null; - twitter_handle?: string | null; - type?: string | null; - uri?: string | null; - youtube_channel_url?: string | null; - }; - Update: { - account_status?: string | null; - apple_token?: string | null; - campaign_id?: string | null; - campaign_interaction_count?: number | null; - campaignId?: string | null; - city?: string | null; - click_through_rate?: number | null; - clientId?: string | null; - consent_given?: boolean | null; - country?: string | null; - custom_tags?: Json | null; - discord_username?: string | null; - display_name?: string | null; - email?: string | null; - email_open_rate?: number | null; - engagement_level?: string | null; - episodes?: Json | null; - explicit_content_filter_enabled?: boolean | null; - explicit_content_filter_locked?: boolean | null; - "explicit_content.filter_enabled"?: boolean | null; - "explicit_content.filter_locked"?: boolean | null; - external_urls_spotify?: string | null; - "external_urls.spotify"?: string | null; - facebook_profile_url?: string | null; - first_stream_date?: string | null; - followedArtists?: Json | null; - followers_total?: number | null; - "followers.href"?: string | null; - "followers.total"?: number | null; - gamification_points?: number | null; - genres?: Json | null; - heavyRotations?: Json | null; - href?: string | null; - id?: string; - images?: Json | null; - instagram_handle?: string | null; - last_campaign_interaction?: string | null; - last_login?: string | null; - last_purchase_date?: string | null; - last_stream_date?: string | null; - linkedin_profile_url?: string | null; - os_type?: string | null; - playlist?: Json | null; - preferences?: Json | null; - preferred_artists?: Json | null; - preferred_device?: string | null; - product?: string | null; - recentlyPlayed?: Json | null; - recommendations?: Json | null; - recommended_events?: Json | null; - reddit_username?: string | null; - saved_podcasts?: Json | null; - savedAlbums?: Json | null; - savedAudioBooks?: Json | null; - savedShows?: Json | null; - savedTracks?: Json | null; - social_shares?: number | null; - spotify_token?: string | null; - subscription_tier?: string | null; - testField?: string | null; - tiktok_handle?: string | null; - time_zone?: string | null; - timestamp?: string | null; - top_artists_long_term?: Json | null; - top_artists_medium_term?: Json | null; - top_tracks_long_term?: Json | null; - top_tracks_medium_term?: Json | null; - top_tracks_short_term?: Json | null; - topArtists?: Json | null; - topTracks?: Json | null; - total_spent?: number | null; - total_streams?: number | null; - twitter_handle?: string | null; - type?: string | null; - uri?: string | null; - youtube_channel_url?: string | null; - }; + account_status: string | null + apple_token: string | null + campaign_id: string | null + campaign_interaction_count: number | null + campaignId: string | null + city: string | null + click_through_rate: number | null + clientId: string | null + consent_given: boolean | null + country: string | null + custom_tags: Json | null + discord_username: string | null + display_name: string | null + email: string | null + email_open_rate: number | null + engagement_level: string | null + episodes: Json | null + explicit_content_filter_enabled: boolean | null + explicit_content_filter_locked: boolean | null + "explicit_content.filter_enabled": boolean | null + "explicit_content.filter_locked": boolean | null + external_urls_spotify: string | null + "external_urls.spotify": string | null + facebook_profile_url: string | null + first_stream_date: string | null + followedArtists: Json | null + followers_total: number | null + "followers.href": string | null + "followers.total": number | null + gamification_points: number | null + genres: Json | null + heavyRotations: Json | null + href: string | null + id: string + images: Json | null + instagram_handle: string | null + last_campaign_interaction: string | null + last_login: string | null + last_purchase_date: string | null + last_stream_date: string | null + linkedin_profile_url: string | null + os_type: string | null + playlist: Json | null + preferences: Json | null + preferred_artists: Json | null + preferred_device: string | null + product: string | null + recentlyPlayed: Json | null + recommendations: Json | null + recommended_events: Json | null + reddit_username: string | null + saved_podcasts: Json | null + savedAlbums: Json | null + savedAudioBooks: Json | null + savedShows: Json | null + savedTracks: Json | null + social_shares: number | null + spotify_token: string | null + subscription_tier: string | null + testField: string | null + tiktok_handle: string | null + time_zone: string | null + timestamp: string | null + top_artists_long_term: Json | null + top_artists_medium_term: Json | null + top_tracks_long_term: Json | null + top_tracks_medium_term: Json | null + top_tracks_short_term: Json | null + topArtists: Json | null + topTracks: Json | null + total_spent: number | null + total_streams: number | null + twitter_handle: string | null + type: string | null + uri: string | null + youtube_channel_url: string | null + } + Insert: { + account_status?: string | null + apple_token?: string | null + campaign_id?: string | null + campaign_interaction_count?: number | null + campaignId?: string | null + city?: string | null + click_through_rate?: number | null + clientId?: string | null + consent_given?: boolean | null + country?: string | null + custom_tags?: Json | null + discord_username?: string | null + display_name?: string | null + email?: string | null + email_open_rate?: number | null + engagement_level?: string | null + episodes?: Json | null + explicit_content_filter_enabled?: boolean | null + explicit_content_filter_locked?: boolean | null + "explicit_content.filter_enabled"?: boolean | null + "explicit_content.filter_locked"?: boolean | null + external_urls_spotify?: string | null + "external_urls.spotify"?: string | null + facebook_profile_url?: string | null + first_stream_date?: string | null + followedArtists?: Json | null + followers_total?: number | null + "followers.href"?: string | null + "followers.total"?: number | null + gamification_points?: number | null + genres?: Json | null + heavyRotations?: Json | null + href?: string | null + id?: string + images?: Json | null + instagram_handle?: string | null + last_campaign_interaction?: string | null + last_login?: string | null + last_purchase_date?: string | null + last_stream_date?: string | null + linkedin_profile_url?: string | null + os_type?: string | null + playlist?: Json | null + preferences?: Json | null + preferred_artists?: Json | null + preferred_device?: string | null + product?: string | null + recentlyPlayed?: Json | null + recommendations?: Json | null + recommended_events?: Json | null + reddit_username?: string | null + saved_podcasts?: Json | null + savedAlbums?: Json | null + savedAudioBooks?: Json | null + savedShows?: Json | null + savedTracks?: Json | null + social_shares?: number | null + spotify_token?: string | null + subscription_tier?: string | null + testField?: string | null + tiktok_handle?: string | null + time_zone?: string | null + timestamp?: string | null + top_artists_long_term?: Json | null + top_artists_medium_term?: Json | null + top_tracks_long_term?: Json | null + top_tracks_medium_term?: Json | null + top_tracks_short_term?: Json | null + topArtists?: Json | null + topTracks?: Json | null + total_spent?: number | null + total_streams?: number | null + twitter_handle?: string | null + type?: string | null + uri?: string | null + youtube_channel_url?: string | null + } + Update: { + account_status?: string | null + apple_token?: string | null + campaign_id?: string | null + campaign_interaction_count?: number | null + campaignId?: string | null + city?: string | null + click_through_rate?: number | null + clientId?: string | null + consent_given?: boolean | null + country?: string | null + custom_tags?: Json | null + discord_username?: string | null + display_name?: string | null + email?: string | null + email_open_rate?: number | null + engagement_level?: string | null + episodes?: Json | null + explicit_content_filter_enabled?: boolean | null + explicit_content_filter_locked?: boolean | null + "explicit_content.filter_enabled"?: boolean | null + "explicit_content.filter_locked"?: boolean | null + external_urls_spotify?: string | null + "external_urls.spotify"?: string | null + facebook_profile_url?: string | null + first_stream_date?: string | null + followedArtists?: Json | null + followers_total?: number | null + "followers.href"?: string | null + "followers.total"?: number | null + gamification_points?: number | null + genres?: Json | null + heavyRotations?: Json | null + href?: string | null + id?: string + images?: Json | null + instagram_handle?: string | null + last_campaign_interaction?: string | null + last_login?: string | null + last_purchase_date?: string | null + last_stream_date?: string | null + linkedin_profile_url?: string | null + os_type?: string | null + playlist?: Json | null + preferences?: Json | null + preferred_artists?: Json | null + preferred_device?: string | null + product?: string | null + recentlyPlayed?: Json | null + recommendations?: Json | null + recommended_events?: Json | null + reddit_username?: string | null + saved_podcasts?: Json | null + savedAlbums?: Json | null + savedAudioBooks?: Json | null + savedShows?: Json | null + savedTracks?: Json | null + social_shares?: number | null + spotify_token?: string | null + subscription_tier?: string | null + testField?: string | null + tiktok_handle?: string | null + time_zone?: string | null + timestamp?: string | null + top_artists_long_term?: Json | null + top_artists_medium_term?: Json | null + top_tracks_long_term?: Json | null + top_tracks_medium_term?: Json | null + top_tracks_short_term?: Json | null + topArtists?: Json | null + topTracks?: Json | null + total_spent?: number | null + total_streams?: number | null + twitter_handle?: string | null + type?: string | null + uri?: string | null + youtube_channel_url?: string | null + } Relationships: [ { - foreignKeyName: "fans_campaignId_fkey"; - columns: ["campaignId"]; - isOneToOne: false; - referencedRelation: "campaigns"; - referencedColumns: ["id"]; + foreignKeyName: "fans_campaignId_fkey" + columns: ["campaignId"] + isOneToOne: false + referencedRelation: "campaigns" + referencedColumns: ["id"] }, - ]; - }; + ] + } files: { Row: { - artist_account_id: string; - created_at: string; - description: string | null; - file_name: string; - id: string; - is_directory: boolean; - mime_type: string | null; - owner_account_id: string; - size_bytes: number | null; - storage_key: string; - tags: string[] | null; - updated_at: string; - }; - Insert: { - artist_account_id: string; - created_at?: string; - description?: string | null; - file_name: string; - id?: string; - is_directory?: boolean; - mime_type?: string | null; - owner_account_id: string; - size_bytes?: number | null; - storage_key: string; - tags?: string[] | null; - updated_at?: string; - }; - Update: { - artist_account_id?: string; - created_at?: string; - description?: string | null; - file_name?: string; - id?: string; - is_directory?: boolean; - mime_type?: string | null; - owner_account_id?: string; - size_bytes?: number | null; - storage_key?: string; - tags?: string[] | null; - updated_at?: string; - }; + artist_account_id: string + created_at: string + description: string | null + file_name: string + id: string + is_directory: boolean + mime_type: string | null + owner_account_id: string + size_bytes: number | null + storage_key: string + tags: string[] | null + updated_at: string + } + Insert: { + artist_account_id: string + created_at?: string + description?: string | null + file_name: string + id?: string + is_directory?: boolean + mime_type?: string | null + owner_account_id: string + size_bytes?: number | null + storage_key: string + tags?: string[] | null + updated_at?: string + } + Update: { + artist_account_id?: string + created_at?: string + description?: string | null + file_name?: string + id?: string + is_directory?: boolean + mime_type?: string | null + owner_account_id?: string + size_bytes?: number | null + storage_key?: string + tags?: string[] | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "files_artist_account_id_fkey"; - columns: ["artist_account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "files_artist_account_id_fkey" + columns: ["artist_account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "files_owner_account_id_fkey"; - columns: ["owner_account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "files_owner_account_id_fkey" + columns: ["owner_account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } follows: { Row: { - game: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + game: string | null + id: string | null + timestamp: number | null + } + Insert: { + game?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + game?: string | null + id?: string | null + timestamp?: number | null + } + Relationships: [] + } founder_dashboard_chart_annotations: { Row: { - chart_type: string | null; - created_at: string | null; - event_date: string; - event_description: string | null; - id: string; - }; - Insert: { - chart_type?: string | null; - created_at?: string | null; - event_date: string; - event_description?: string | null; - id?: string; - }; - Update: { - chart_type?: string | null; - created_at?: string | null; - event_date?: string; - event_description?: string | null; - id?: string; - }; - Relationships: []; - }; + chart_type: string | null + created_at: string | null + event_date: string + event_description: string | null + id: string + } + Insert: { + chart_type?: string | null + created_at?: string | null + event_date: string + event_description?: string | null + id?: string + } + Update: { + chart_type?: string | null + created_at?: string | null + event_date?: string + event_description?: string | null + id?: string + } + Relationships: [] + } funnel_analytics: { Row: { - artist_id: string | null; - handle: string | null; - id: string; - pilot_id: string | null; - status: number | null; - type: Database["public"]["Enums"]["social_type"] | null; - updated_at: string; - }; - Insert: { - artist_id?: string | null; - handle?: string | null; - id?: string; - pilot_id?: string | null; - status?: number | null; - type?: Database["public"]["Enums"]["social_type"] | null; - updated_at?: string; - }; - Update: { - artist_id?: string | null; - handle?: string | null; - id?: string; - pilot_id?: string | null; - status?: number | null; - type?: Database["public"]["Enums"]["social_type"] | null; - updated_at?: string; - }; + artist_id: string | null + handle: string | null + id: string + pilot_id: string | null + status: number | null + type: Database["public"]["Enums"]["social_type"] | null + updated_at: string + } + Insert: { + artist_id?: string | null + handle?: string | null + id?: string + pilot_id?: string | null + status?: number | null + type?: Database["public"]["Enums"]["social_type"] | null + updated_at?: string + } + Update: { + artist_id?: string | null + handle?: string | null + id?: string + pilot_id?: string | null + status?: number | null + type?: Database["public"]["Enums"]["social_type"] | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "funnel_analytics_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "funnel_analytics_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } funnel_analytics_accounts: { Row: { - account_id: string | null; - analysis_id: string | null; - created_at: string; - id: string; - updated_at: string | null; - }; - Insert: { - account_id?: string | null; - analysis_id?: string | null; - created_at?: string; - id?: string; - updated_at?: string | null; - }; - Update: { - account_id?: string | null; - analysis_id?: string | null; - created_at?: string; - id?: string; - updated_at?: string | null; - }; + account_id: string | null + analysis_id: string | null + created_at: string + id: string + updated_at: string | null + } + Insert: { + account_id?: string | null + analysis_id?: string | null + created_at?: string + id?: string + updated_at?: string | null + } + Update: { + account_id?: string | null + analysis_id?: string | null + created_at?: string + id?: string + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "account_funnel_analytics_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "account_funnel_analytics_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "account_funnel_analytics_analysis_id_fkey"; - columns: ["analysis_id"]; - isOneToOne: false; - referencedRelation: "funnel_analytics"; - referencedColumns: ["id"]; + foreignKeyName: "account_funnel_analytics_analysis_id_fkey" + columns: ["analysis_id"] + isOneToOne: false + referencedRelation: "funnel_analytics" + referencedColumns: ["id"] }, - ]; - }; + ] + } funnel_analytics_segments: { Row: { - analysis_id: string | null; - created_at: string; - icon: string | null; - id: string; - name: string | null; - size: number | null; - }; - Insert: { - analysis_id?: string | null; - created_at?: string; - icon?: string | null; - id?: string; - name?: string | null; - size?: number | null; - }; - Update: { - analysis_id?: string | null; - created_at?: string; - icon?: string | null; - id?: string; - name?: string | null; - size?: number | null; - }; + analysis_id: string | null + created_at: string + icon: string | null + id: string + name: string | null + size: number | null + } + Insert: { + analysis_id?: string | null + created_at?: string + icon?: string | null + id?: string + name?: string | null + size?: number | null + } + Update: { + analysis_id?: string | null + created_at?: string + icon?: string | null + id?: string + name?: string | null + size?: number | null + } Relationships: [ { - foreignKeyName: "funnel_analytics_segments_analysis_id_fkey"; - columns: ["analysis_id"]; - isOneToOne: false; - referencedRelation: "funnel_analytics"; - referencedColumns: ["id"]; + foreignKeyName: "funnel_analytics_segments_analysis_id_fkey" + columns: ["analysis_id"] + isOneToOne: false + referencedRelation: "funnel_analytics" + referencedColumns: ["id"] }, - ]; - }; + ] + } funnel_reports: { Row: { - id: string; - next_steps: string | null; - report: string | null; - stack_unique_id: string | null; - timestamp: string; - type: Database["public"]["Enums"]["social_type"] | null; - }; - Insert: { - id?: string; - next_steps?: string | null; - report?: string | null; - stack_unique_id?: string | null; - timestamp?: string; - type?: Database["public"]["Enums"]["social_type"] | null; - }; - Update: { - id?: string; - next_steps?: string | null; - report?: string | null; - stack_unique_id?: string | null; - timestamp?: string; - type?: Database["public"]["Enums"]["social_type"] | null; - }; - Relationships: []; - }; + id: string + next_steps: string | null + report: string | null + stack_unique_id: string | null + timestamp: string + type: Database["public"]["Enums"]["social_type"] | null + } + Insert: { + id?: string + next_steps?: string | null + report?: string | null + stack_unique_id?: string | null + timestamp?: string + type?: Database["public"]["Enums"]["social_type"] | null + } + Update: { + id?: string + next_steps?: string | null + report?: string | null + stack_unique_id?: string | null + timestamp?: string + type?: Database["public"]["Enums"]["social_type"] | null + } + Relationships: [] + } game_start: { Row: { - clientId: string | null; - fanId: Json | null; - game: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - clientId?: string | null; - fanId?: Json | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - clientId?: string | null; - fanId?: Json | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + clientId: string | null + fanId: Json | null + game: string | null + id: string | null + timestamp: number | null + } + Insert: { + clientId?: string | null + fanId?: Json | null + game?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + clientId?: string | null + fanId?: Json | null + game?: string | null + id?: string | null + timestamp?: number | null + } + Relationships: [] + } invitations: { Row: { - account_id: string; - created_at: string; - email: string; - expires_at: string; - id: number; - invite_token: string; - invited_by: string; - role: string; - updated_at: string; - }; - Insert: { - account_id: string; - created_at?: string; - email: string; - expires_at?: string; - id?: number; - invite_token: string; - invited_by: string; - role: string; - updated_at?: string; - }; - Update: { - account_id?: string; - created_at?: string; - email?: string; - expires_at?: string; - id?: number; - invite_token?: string; - invited_by?: string; - role?: string; - updated_at?: string; - }; + account_id: string + created_at: string + email: string + expires_at: string + id: number + invite_token: string + invited_by: string + role: string + updated_at: string + } + Insert: { + account_id: string + created_at?: string + email: string + expires_at?: string + id?: number + invite_token: string + invited_by: string + role: string + updated_at?: string + } + Update: { + account_id?: string + created_at?: string + email?: string + expires_at?: string + id?: number + invite_token?: string + invited_by?: string + role?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "invitations_role_fkey"; - columns: ["role"]; - isOneToOne: false; - referencedRelation: "roles"; - referencedColumns: ["name"]; + foreignKeyName: "invitations_role_fkey" + columns: ["role"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["name"] }, - ]; - }; + ] + } ios_redirect: { Row: { - clientId: string | null; - fanId: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - clientId?: string | null; - fanId?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - clientId?: string | null; - fanId?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + clientId: string | null + fanId: string | null + id: string | null + timestamp: number | null + } + Insert: { + clientId?: string | null + fanId?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + clientId?: string | null + fanId?: string | null + id?: string | null + timestamp?: number | null + } + Relationships: [] + } leaderboard: { Row: { - id: string | null; - Name: string | null; - Number: string | null; - Score: string | null; - Spotify: string | null; - "Time._nanoseconds": string | null; - "Time._seconds": string | null; - }; - Insert: { - id?: string | null; - Name?: string | null; - Number?: string | null; - Score?: string | null; - Spotify?: string | null; - "Time._nanoseconds"?: string | null; - "Time._seconds"?: string | null; - }; - Update: { - id?: string | null; - Name?: string | null; - Number?: string | null; - Score?: string | null; - Spotify?: string | null; - "Time._nanoseconds"?: string | null; - "Time._seconds"?: string | null; - }; - Relationships: []; - }; + id: string | null + Name: string | null + Number: string | null + Score: string | null + Spotify: string | null + "Time._nanoseconds": string | null + "Time._seconds": string | null + } + Insert: { + id?: string | null + Name?: string | null + Number?: string | null + Score?: string | null + Spotify?: string | null + "Time._nanoseconds"?: string | null + "Time._seconds"?: string | null + } + Update: { + id?: string | null + Name?: string | null + Number?: string | null + Score?: string | null + Spotify?: string | null + "Time._nanoseconds"?: string | null + "Time._seconds"?: string | null + } + Relationships: [] + } leaderboard_boogie: { Row: { - clientId: string | null; - displayName: string | null; - fanId: string | null; - gameType: string | null; - id: string | null; - score: number | null; - timestamp: string | null; - }; - Insert: { - clientId?: string | null; - displayName?: string | null; - fanId?: string | null; - gameType?: string | null; - id?: string | null; - score?: number | null; - timestamp?: string | null; - }; - Update: { - clientId?: string | null; - displayName?: string | null; - fanId?: string | null; - gameType?: string | null; - id?: string | null; - score?: number | null; - timestamp?: string | null; - }; - Relationships: []; - }; + clientId: string | null + displayName: string | null + fanId: string | null + gameType: string | null + id: string | null + score: number | null + timestamp: string | null + } + Insert: { + clientId?: string | null + displayName?: string | null + fanId?: string | null + gameType?: string | null + id?: string | null + score?: number | null + timestamp?: string | null + } + Update: { + clientId?: string | null + displayName?: string | null + fanId?: string | null + gameType?: string | null + id?: string | null + score?: number | null + timestamp?: string | null + } + Relationships: [] + } leaderboard_luh_tyler_3d: { Row: { - FanId: string | null; - id: string | null; - Score: string | null; - ScorePerTime: string | null; - Time: string | null; - timestamp: string | null; - UserName: string | null; - }; - Insert: { - FanId?: string | null; - id?: string | null; - Score?: string | null; - ScorePerTime?: string | null; - Time?: string | null; - timestamp?: string | null; - UserName?: string | null; - }; - Update: { - FanId?: string | null; - id?: string | null; - Score?: string | null; - ScorePerTime?: string | null; - Time?: string | null; - timestamp?: string | null; - UserName?: string | null; - }; - Relationships: []; - }; + FanId: string | null + id: string | null + Score: string | null + ScorePerTime: string | null + Time: string | null + timestamp: string | null + UserName: string | null + } + Insert: { + FanId?: string | null + id?: string | null + Score?: string | null + ScorePerTime?: string | null + Time?: string | null + timestamp?: string | null + UserName?: string | null + } + Update: { + FanId?: string | null + id?: string | null + Score?: string | null + ScorePerTime?: string | null + Time?: string | null + timestamp?: string | null + UserName?: string | null + } + Relationships: [] + } leaderboard_luv: { Row: { - f: string | null; - id: string | null; - }; + f: string | null + id: string | null + } Insert: { - f?: string | null; - id?: string | null; - }; + f?: string | null + id?: string | null + } Update: { - f?: string | null; - id?: string | null; - }; - Relationships: []; - }; + f?: string | null + id?: string | null + } + Relationships: [] + } memories: { Row: { - content: Json; - id: string; - room_id: string | null; - updated_at: string; - }; - Insert: { - content: Json; - id?: string; - room_id?: string | null; - updated_at?: string; - }; - Update: { - content?: Json; - id?: string; - room_id?: string | null; - updated_at?: string; - }; + content: Json + id: string + room_id: string | null + updated_at: string + } + Insert: { + content: Json + id?: string + room_id?: string | null + updated_at?: string + } + Update: { + content?: Json + id?: string + room_id?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "memories_room_id_fkey"; - columns: ["room_id"]; - isOneToOne: false; - referencedRelation: "rooms"; - referencedColumns: ["id"]; + foreignKeyName: "memories_room_id_fkey" + columns: ["room_id"] + isOneToOne: false + referencedRelation: "rooms" + referencedColumns: ["id"] }, - ]; - }; + ] + } memory_emails: { Row: { - created_at: string; - email_id: string; - id: string; - memory: string; - message_id: string; - }; - Insert: { - created_at?: string; - email_id: string; - id?: string; - memory: string; - message_id: string; - }; - Update: { - created_at?: string; - email_id?: string; - id?: string; - memory?: string; - message_id?: string; - }; + created_at: string + email_id: string + id: string + memory: string + message_id: string + } + Insert: { + created_at?: string + email_id: string + id?: string + memory: string + message_id: string + } + Update: { + created_at?: string + email_id?: string + id?: string + memory?: string + message_id?: string + } Relationships: [ { - foreignKeyName: "memory_emails_memory_fkey"; - columns: ["memory"]; - isOneToOne: false; - referencedRelation: "memories"; - referencedColumns: ["id"]; + foreignKeyName: "memory_emails_memory_fkey" + columns: ["memory"] + isOneToOne: false + referencedRelation: "memories" + referencedColumns: ["id"] }, - ]; - }; + ] + } notifications: { Row: { - account_id: string; - body: string; - channel: Database["public"]["Enums"]["notification_channel"]; - created_at: string; - dismissed: boolean; - expires_at: string | null; - id: number; - link: string | null; - type: Database["public"]["Enums"]["notification_type"]; - }; - Insert: { - account_id: string; - body: string; - channel?: Database["public"]["Enums"]["notification_channel"]; - created_at?: string; - dismissed?: boolean; - expires_at?: string | null; - id?: never; - link?: string | null; - type?: Database["public"]["Enums"]["notification_type"]; - }; - Update: { - account_id?: string; - body?: string; - channel?: Database["public"]["Enums"]["notification_channel"]; - created_at?: string; - dismissed?: boolean; - expires_at?: string | null; - id?: never; - link?: string | null; - type?: Database["public"]["Enums"]["notification_type"]; - }; - Relationships: []; - }; + account_id: string + body: string + channel: Database["public"]["Enums"]["notification_channel"] + created_at: string + dismissed: boolean + expires_at: string | null + id: number + link: string | null + type: Database["public"]["Enums"]["notification_type"] + } + Insert: { + account_id: string + body: string + channel?: Database["public"]["Enums"]["notification_channel"] + created_at?: string + dismissed?: boolean + expires_at?: string | null + id?: never + link?: string | null + type?: Database["public"]["Enums"]["notification_type"] + } + Update: { + account_id?: string + body?: string + channel?: Database["public"]["Enums"]["notification_channel"] + created_at?: string + dismissed?: boolean + expires_at?: string | null + id?: never + link?: string | null + type?: Database["public"]["Enums"]["notification_type"] + } + Relationships: [] + } order_items: { Row: { - created_at: string; - id: string; - order_id: string; - price_amount: number | null; - product_id: string; - quantity: number; - updated_at: string; - variant_id: string; - }; - Insert: { - created_at?: string; - id: string; - order_id: string; - price_amount?: number | null; - product_id: string; - quantity?: number; - updated_at?: string; - variant_id: string; - }; - Update: { - created_at?: string; - id?: string; - order_id?: string; - price_amount?: number | null; - product_id?: string; - quantity?: number; - updated_at?: string; - variant_id?: string; - }; + created_at: string + id: string + order_id: string + price_amount: number | null + product_id: string + quantity: number + updated_at: string + variant_id: string + } + Insert: { + created_at?: string + id: string + order_id: string + price_amount?: number | null + product_id: string + quantity?: number + updated_at?: string + variant_id: string + } + Update: { + created_at?: string + id?: string + order_id?: string + price_amount?: number | null + product_id?: string + quantity?: number + updated_at?: string + variant_id?: string + } Relationships: [ { - foreignKeyName: "order_items_order_id_fkey"; - columns: ["order_id"]; - isOneToOne: false; - referencedRelation: "orders"; - referencedColumns: ["id"]; + foreignKeyName: "order_items_order_id_fkey" + columns: ["order_id"] + isOneToOne: false + referencedRelation: "orders" + referencedColumns: ["id"] }, - ]; - }; + ] + } orders: { Row: { - account_id: string; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - created_at: string; - currency: string; - id: string; - status: Database["public"]["Enums"]["payment_status"]; - total_amount: number; - updated_at: string; - }; - Insert: { - account_id: string; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - created_at?: string; - currency: string; - id: string; - status: Database["public"]["Enums"]["payment_status"]; - total_amount: number; - updated_at?: string; - }; - Update: { - account_id?: string; - billing_customer_id?: number; - billing_provider?: Database["public"]["Enums"]["billing_provider"]; - created_at?: string; - currency?: string; - id?: string; - status?: Database["public"]["Enums"]["payment_status"]; - total_amount?: number; - updated_at?: string; - }; + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at: string + currency: string + id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at: string + } + Insert: { + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at?: string + currency: string + id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at?: string + } + Update: { + account_id?: string + billing_customer_id?: number + billing_provider?: Database["public"]["Enums"]["billing_provider"] + created_at?: string + currency?: string + id?: string + status?: Database["public"]["Enums"]["payment_status"] + total_amount?: number + updated_at?: string + } Relationships: [ { - foreignKeyName: "orders_billing_customer_id_fkey"; - columns: ["billing_customer_id"]; - isOneToOne: false; - referencedRelation: "billing_customers"; - referencedColumns: ["id"]; + foreignKeyName: "orders_billing_customer_id_fkey" + columns: ["billing_customer_id"] + isOneToOne: false + referencedRelation: "billing_customers" + referencedColumns: ["id"] }, - ]; - }; + ] + } organization_domains: { Row: { - created_at: string | null; - domain: string; - id: string; - organization_id: string; - }; - Insert: { - created_at?: string | null; - domain: string; - id?: string; - organization_id: string; - }; - Update: { - created_at?: string | null; - domain?: string; - id?: string; - organization_id?: string; - }; + created_at: string | null + domain: string + id: string + organization_id: string + } + Insert: { + created_at?: string | null + domain: string + id?: string + organization_id: string + } + Update: { + created_at?: string | null + domain?: string + id?: string + organization_id?: string + } Relationships: [ { - foreignKeyName: "organization_domains_organization_id_fkey"; - columns: ["organization_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "organization_domains_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } plans: { Row: { - name: string; - tokens_quota: number; - variant_id: string; - }; - Insert: { - name: string; - tokens_quota: number; - variant_id: string; - }; - Update: { - name?: string; - tokens_quota?: number; - variant_id?: string; - }; - Relationships: []; - }; + name: string + tokens_quota: number + variant_id: string + } + Insert: { + name: string + tokens_quota: number + variant_id: string + } + Update: { + name?: string + tokens_quota?: number + variant_id?: string + } + Relationships: [] + } popup_open: { Row: { - campaignId: string | null; - clientId: string | null; - fanId: string | null; - game: string | null; - id: string | null; - timestamp: string | null; - }; - Insert: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: string | null; - }; - Update: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: string | null; - }; - Relationships: []; - }; + campaignId: string | null + clientId: string | null + fanId: string | null + game: string | null + id: string | null + timestamp: string | null + } + Insert: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: string | null + } + Update: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: string | null + } + Relationships: [] + } post_comments: { Row: { - comment: string | null; - commented_at: string; - id: string; - post_id: string | null; - social_id: string | null; - }; - Insert: { - comment?: string | null; - commented_at: string; - id?: string; - post_id?: string | null; - social_id?: string | null; - }; - Update: { - comment?: string | null; - commented_at?: string; - id?: string; - post_id?: string | null; - social_id?: string | null; - }; + comment: string | null + commented_at: string + id: string + post_id: string | null + social_id: string | null + } + Insert: { + comment?: string | null + commented_at: string + id?: string + post_id?: string | null + social_id?: string | null + } + Update: { + comment?: string | null + commented_at?: string + id?: string + post_id?: string | null + social_id?: string | null + } Relationships: [ { - foreignKeyName: "post_comments_post_id_fkey"; - columns: ["post_id"]; - isOneToOne: false; - referencedRelation: "posts"; - referencedColumns: ["id"]; + foreignKeyName: "post_comments_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] }, { - foreignKeyName: "post_comments_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "post_comments_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } posts: { Row: { - id: string; - post_url: string; - updated_at: string; - }; - Insert: { - id?: string; - post_url: string; - updated_at?: string; - }; - Update: { - id?: string; - post_url?: string; - updated_at?: string; - }; - Relationships: []; - }; + id: string + post_url: string + updated_at: string + } + Insert: { + id?: string + post_url: string + updated_at?: string + } + Update: { + id?: string + post_url?: string + updated_at?: string + } + Relationships: [] + } presave: { Row: { - accessToken: string | null; - fanId: string | null; - "fanId.error.code": string | null; - "fanId.error.name": string | null; - id: string | null; - presaveId: string | null; - presaveReleaseDate: string | null; - refreshToken: string | null; - timestamp: number | null; - }; - Insert: { - accessToken?: string | null; - fanId?: string | null; - "fanId.error.code"?: string | null; - "fanId.error.name"?: string | null; - id?: string | null; - presaveId?: string | null; - presaveReleaseDate?: string | null; - refreshToken?: string | null; - timestamp?: number | null; - }; - Update: { - accessToken?: string | null; - fanId?: string | null; - "fanId.error.code"?: string | null; - "fanId.error.name"?: string | null; - id?: string | null; - presaveId?: string | null; - presaveReleaseDate?: string | null; - refreshToken?: string | null; - timestamp?: number | null; - }; - Relationships: []; - }; + accessToken: string | null + fanId: string | null + "fanId.error.code": string | null + "fanId.error.name": string | null + id: string | null + presaveId: string | null + presaveReleaseDate: string | null + refreshToken: string | null + timestamp: number | null + } + Insert: { + accessToken?: string | null + fanId?: string | null + "fanId.error.code"?: string | null + "fanId.error.name"?: string | null + id?: string | null + presaveId?: string | null + presaveReleaseDate?: string | null + refreshToken?: string | null + timestamp?: number | null + } + Update: { + accessToken?: string | null + fanId?: string | null + "fanId.error.code"?: string | null + "fanId.error.name"?: string | null + id?: string | null + presaveId?: string | null + presaveReleaseDate?: string | null + refreshToken?: string | null + timestamp?: number | null + } + Relationships: [] + } pulse_accounts: { Row: { - account_id: string; - active: boolean; - id: string; - }; + account_id: string + active: boolean + id: string + } Insert: { - account_id: string; - active?: boolean; - id?: string; - }; + account_id: string + active?: boolean + id?: string + } Update: { - account_id?: string; - active?: boolean; - id?: string; - }; + account_id?: string + active?: boolean + id?: string + } Relationships: [ { - foreignKeyName: "pulse_accounts_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: true; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "pulse_accounts_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } role_permissions: { Row: { - id: number; - permission: Database["public"]["Enums"]["app_permissions"]; - role: string; - }; + id: number + permission: Database["public"]["Enums"]["app_permissions"] + role: string + } Insert: { - id?: number; - permission: Database["public"]["Enums"]["app_permissions"]; - role: string; - }; + id?: number + permission: Database["public"]["Enums"]["app_permissions"] + role: string + } Update: { - id?: number; - permission?: Database["public"]["Enums"]["app_permissions"]; - role?: string; - }; + id?: number + permission?: Database["public"]["Enums"]["app_permissions"] + role?: string + } Relationships: [ { - foreignKeyName: "role_permissions_role_fkey"; - columns: ["role"]; - isOneToOne: false; - referencedRelation: "roles"; - referencedColumns: ["name"]; + foreignKeyName: "role_permissions_role_fkey" + columns: ["role"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["name"] }, - ]; - }; + ] + } roles: { Row: { - hierarchy_level: number; - name: string; - }; + hierarchy_level: number + name: string + } Insert: { - hierarchy_level: number; - name: string; - }; + hierarchy_level: number + name: string + } Update: { - hierarchy_level?: number; - name?: string; - }; - Relationships: []; - }; + hierarchy_level?: number + name?: string + } + Relationships: [] + } room_reports: { Row: { - id: string; - report_id: string; - room_id: string | null; - }; + id: string + report_id: string + room_id: string | null + } Insert: { - id?: string; - report_id?: string; - room_id?: string | null; - }; + id?: string + report_id?: string + room_id?: string | null + } Update: { - id?: string; - report_id?: string; - room_id?: string | null; - }; + id?: string + report_id?: string + room_id?: string | null + } Relationships: [ { - foreignKeyName: "room_reports_report_id_fkey"; - columns: ["report_id"]; - isOneToOne: false; - referencedRelation: "segment_reports"; - referencedColumns: ["id"]; + foreignKeyName: "room_reports_report_id_fkey" + columns: ["report_id"] + isOneToOne: false + referencedRelation: "segment_reports" + referencedColumns: ["id"] }, { - foreignKeyName: "room_reports_room_id_fkey"; - columns: ["room_id"]; - isOneToOne: false; - referencedRelation: "rooms"; - referencedColumns: ["id"]; + foreignKeyName: "room_reports_room_id_fkey" + columns: ["room_id"] + isOneToOne: false + referencedRelation: "rooms" + referencedColumns: ["id"] }, - ]; - }; + ] + } rooms: { Row: { - account_id: string | null; - artist_id: string | null; - id: string; - topic: string | null; - updated_at: string; - }; - Insert: { - account_id?: string | null; - artist_id?: string | null; - id?: string; - topic?: string | null; - updated_at?: string; - }; - Update: { - account_id?: string | null; - artist_id?: string | null; - id?: string; - topic?: string | null; - updated_at?: string; - }; + account_id: string | null + artist_id: string | null + id: string + topic: string | null + updated_at: string + } + Insert: { + account_id?: string | null + artist_id?: string | null + id?: string + topic?: string | null + updated_at?: string + } + Update: { + account_id?: string | null + artist_id?: string | null + id?: string + topic?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "rooms_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "rooms_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } sales_pipeline_customers: { Row: { - activity_count: number | null; - assigned_to: string | null; - company_size: string | null; - competitors: string[] | null; - contact_email: string | null; - contact_name: string | null; - contact_phone: string | null; - contacts: Json | null; - conversion_stage: string | null; - conversion_target_date: string | null; - created_at: string | null; - current_artists: number; - current_mrr: number; - custom_fields: Json | null; - days_in_stage: number | null; - domain: string | null; - email: string | null; - engagement_health: string | null; - expected_close_date: string | null; - external_ids: Json | null; - id: string; - industry: string | null; - internal_owner: string | null; - last_activity_date: string | null; - last_activity_type: string | null; - last_contact_date: string; - logo_url: string | null; - lost_reason: string | null; - name: string; - next_action: string | null; - next_activity_date: string | null; - next_activity_type: string | null; - notes: string | null; - order_index: number | null; - organization: string | null; - potential_artists: number; - potential_mrr: number; - priority: string | null; - probability: number | null; - recoupable_user_id: string | null; - source: string | null; - stage: string; - stage_entered_at: string | null; - tags: string[] | null; - todos: Json | null; - trial_end_date: string | null; - trial_start_date: string | null; - type: string | null; - updated_at: string | null; - use_case_type: string | null; - website: string | null; - weighted_mrr: number | null; - win_reason: string | null; - }; - Insert: { - activity_count?: number | null; - assigned_to?: string | null; - company_size?: string | null; - competitors?: string[] | null; - contact_email?: string | null; - contact_name?: string | null; - contact_phone?: string | null; - contacts?: Json | null; - conversion_stage?: string | null; - conversion_target_date?: string | null; - created_at?: string | null; - current_artists?: number; - current_mrr?: number; - custom_fields?: Json | null; - days_in_stage?: number | null; - domain?: string | null; - email?: string | null; - engagement_health?: string | null; - expected_close_date?: string | null; - external_ids?: Json | null; - id?: string; - industry?: string | null; - internal_owner?: string | null; - last_activity_date?: string | null; - last_activity_type?: string | null; - last_contact_date?: string; - logo_url?: string | null; - lost_reason?: string | null; - name: string; - next_action?: string | null; - next_activity_date?: string | null; - next_activity_type?: string | null; - notes?: string | null; - order_index?: number | null; - organization?: string | null; - potential_artists?: number; - potential_mrr?: number; - priority?: string | null; - probability?: number | null; - recoupable_user_id?: string | null; - source?: string | null; - stage: string; - stage_entered_at?: string | null; - tags?: string[] | null; - todos?: Json | null; - trial_end_date?: string | null; - trial_start_date?: string | null; - type?: string | null; - updated_at?: string | null; - use_case_type?: string | null; - website?: string | null; - weighted_mrr?: number | null; - win_reason?: string | null; - }; - Update: { - activity_count?: number | null; - assigned_to?: string | null; - company_size?: string | null; - competitors?: string[] | null; - contact_email?: string | null; - contact_name?: string | null; - contact_phone?: string | null; - contacts?: Json | null; - conversion_stage?: string | null; - conversion_target_date?: string | null; - created_at?: string | null; - current_artists?: number; - current_mrr?: number; - custom_fields?: Json | null; - days_in_stage?: number | null; - domain?: string | null; - email?: string | null; - engagement_health?: string | null; - expected_close_date?: string | null; - external_ids?: Json | null; - id?: string; - industry?: string | null; - internal_owner?: string | null; - last_activity_date?: string | null; - last_activity_type?: string | null; - last_contact_date?: string; - logo_url?: string | null; - lost_reason?: string | null; - name?: string; - next_action?: string | null; - next_activity_date?: string | null; - next_activity_type?: string | null; - notes?: string | null; - order_index?: number | null; - organization?: string | null; - potential_artists?: number; - potential_mrr?: number; - priority?: string | null; - probability?: number | null; - recoupable_user_id?: string | null; - source?: string | null; - stage?: string; - stage_entered_at?: string | null; - tags?: string[] | null; - todos?: Json | null; - trial_end_date?: string | null; - trial_start_date?: string | null; - type?: string | null; - updated_at?: string | null; - use_case_type?: string | null; - website?: string | null; - weighted_mrr?: number | null; - win_reason?: string | null; - }; - Relationships: []; - }; + activity_count: number | null + assigned_to: string | null + company_size: string | null + competitors: string[] | null + contact_email: string | null + contact_name: string | null + contact_phone: string | null + contacts: Json | null + conversion_stage: string | null + conversion_target_date: string | null + created_at: string | null + current_artists: number + current_mrr: number + custom_fields: Json | null + days_in_stage: number | null + domain: string | null + email: string | null + engagement_health: string | null + expected_close_date: string | null + external_ids: Json | null + id: string + industry: string | null + internal_owner: string | null + last_activity_date: string | null + last_activity_type: string | null + last_contact_date: string + logo_url: string | null + lost_reason: string | null + name: string + next_action: string | null + next_activity_date: string | null + next_activity_type: string | null + notes: string | null + order_index: number | null + organization: string | null + potential_artists: number + potential_mrr: number + priority: string | null + probability: number | null + recoupable_user_id: string | null + source: string | null + stage: string + stage_entered_at: string | null + tags: string[] | null + todos: Json | null + trial_end_date: string | null + trial_start_date: string | null + type: string | null + updated_at: string | null + use_case_type: string | null + website: string | null + weighted_mrr: number | null + win_reason: string | null + } + Insert: { + activity_count?: number | null + assigned_to?: string | null + company_size?: string | null + competitors?: string[] | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + contacts?: Json | null + conversion_stage?: string | null + conversion_target_date?: string | null + created_at?: string | null + current_artists?: number + current_mrr?: number + custom_fields?: Json | null + days_in_stage?: number | null + domain?: string | null + email?: string | null + engagement_health?: string | null + expected_close_date?: string | null + external_ids?: Json | null + id?: string + industry?: string | null + internal_owner?: string | null + last_activity_date?: string | null + last_activity_type?: string | null + last_contact_date?: string + logo_url?: string | null + lost_reason?: string | null + name: string + next_action?: string | null + next_activity_date?: string | null + next_activity_type?: string | null + notes?: string | null + order_index?: number | null + organization?: string | null + potential_artists?: number + potential_mrr?: number + priority?: string | null + probability?: number | null + recoupable_user_id?: string | null + source?: string | null + stage: string + stage_entered_at?: string | null + tags?: string[] | null + todos?: Json | null + trial_end_date?: string | null + trial_start_date?: string | null + type?: string | null + updated_at?: string | null + use_case_type?: string | null + website?: string | null + weighted_mrr?: number | null + win_reason?: string | null + } + Update: { + activity_count?: number | null + assigned_to?: string | null + company_size?: string | null + competitors?: string[] | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + contacts?: Json | null + conversion_stage?: string | null + conversion_target_date?: string | null + created_at?: string | null + current_artists?: number + current_mrr?: number + custom_fields?: Json | null + days_in_stage?: number | null + domain?: string | null + email?: string | null + engagement_health?: string | null + expected_close_date?: string | null + external_ids?: Json | null + id?: string + industry?: string | null + internal_owner?: string | null + last_activity_date?: string | null + last_activity_type?: string | null + last_contact_date?: string + logo_url?: string | null + lost_reason?: string | null + name?: string + next_action?: string | null + next_activity_date?: string | null + next_activity_type?: string | null + notes?: string | null + order_index?: number | null + organization?: string | null + potential_artists?: number + potential_mrr?: number + priority?: string | null + probability?: number | null + recoupable_user_id?: string | null + source?: string | null + stage?: string + stage_entered_at?: string | null + tags?: string[] | null + todos?: Json | null + trial_end_date?: string | null + trial_start_date?: string | null + type?: string | null + updated_at?: string | null + use_case_type?: string | null + website?: string | null + weighted_mrr?: number | null + win_reason?: string | null + } + Relationships: [] + } save_track: { Row: { - game: string | null; - id: string | null; - timestamp: string | null; - }; - Insert: { - game?: string | null; - id?: string | null; - timestamp?: string | null; - }; - Update: { - game?: string | null; - id?: string | null; - timestamp?: string | null; - }; - Relationships: []; - }; + game: string | null + id: string | null + timestamp: string | null + } + Insert: { + game?: string | null + id?: string | null + timestamp?: string | null + } + Update: { + game?: string | null + id?: string | null + timestamp?: string | null + } + Relationships: [] + } scheduled_actions: { Row: { - account_id: string; - artist_account_id: string; - created_at: string | null; - enabled: boolean | null; - id: string; - last_run: string | null; - model: string | null; - next_run: string | null; - prompt: string; - schedule: string; - title: string; - trigger_schedule_id: string | null; - updated_at: string | null; - }; - Insert: { - account_id: string; - artist_account_id: string; - created_at?: string | null; - enabled?: boolean | null; - id?: string; - last_run?: string | null; - model?: string | null; - next_run?: string | null; - prompt: string; - schedule: string; - title: string; - trigger_schedule_id?: string | null; - updated_at?: string | null; - }; - Update: { - account_id?: string; - artist_account_id?: string; - created_at?: string | null; - enabled?: boolean | null; - id?: string; - last_run?: string | null; - model?: string | null; - next_run?: string | null; - prompt?: string; - schedule?: string; - title?: string; - trigger_schedule_id?: string | null; - updated_at?: string | null; - }; + account_id: string + artist_account_id: string + created_at: string | null + enabled: boolean | null + id: string + last_run: string | null + model: string | null + next_run: string | null + prompt: string + schedule: string + title: string + trigger_schedule_id: string | null + updated_at: string | null + } + Insert: { + account_id: string + artist_account_id: string + created_at?: string | null + enabled?: boolean | null + id?: string + last_run?: string | null + model?: string | null + next_run?: string | null + prompt: string + schedule: string + title: string + trigger_schedule_id?: string | null + updated_at?: string | null + } + Update: { + account_id?: string + artist_account_id?: string + created_at?: string | null + enabled?: boolean | null + id?: string + last_run?: string | null + model?: string | null + next_run?: string | null + prompt?: string + schedule?: string + title?: string + trigger_schedule_id?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "scheduled_actions_account_id_fkey"; - columns: ["account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "scheduled_actions_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "scheduled_actions_artist_account_id_fkey"; - columns: ["artist_account_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "scheduled_actions_artist_account_id_fkey" + columns: ["artist_account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } segment_reports: { Row: { - artist_id: string | null; - id: string; - next_steps: string | null; - report: string | null; - updated_at: string | null; - }; - Insert: { - artist_id?: string | null; - id?: string; - next_steps?: string | null; - report?: string | null; - updated_at?: string | null; - }; - Update: { - artist_id?: string | null; - id?: string; - next_steps?: string | null; - report?: string | null; - updated_at?: string | null; - }; + artist_id: string | null + id: string + next_steps: string | null + report: string | null + updated_at: string | null + } + Insert: { + artist_id?: string | null + id?: string + next_steps?: string | null + report?: string | null + updated_at?: string | null + } + Update: { + artist_id?: string | null + id?: string + next_steps?: string | null + report?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "segment_reports_artist_id_fkey"; - columns: ["artist_id"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "segment_reports_artist_id_fkey" + columns: ["artist_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; + ] + } segment_rooms: { Row: { - id: string; - room_id: string; - segment_id: string; - updated_at: string; - }; - Insert: { - id?: string; - room_id: string; - segment_id: string; - updated_at?: string; - }; - Update: { - id?: string; - room_id?: string; - segment_id?: string; - updated_at?: string; - }; + id: string + room_id: string + segment_id: string + updated_at: string + } + Insert: { + id?: string + room_id: string + segment_id: string + updated_at?: string + } + Update: { + id?: string + room_id?: string + segment_id?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "segment_rooms_room_id_fkey"; - columns: ["room_id"]; - isOneToOne: false; - referencedRelation: "rooms"; - referencedColumns: ["id"]; + foreignKeyName: "segment_rooms_room_id_fkey" + columns: ["room_id"] + isOneToOne: false + referencedRelation: "rooms" + referencedColumns: ["id"] }, { - foreignKeyName: "segment_rooms_segment_id_fkey"; - columns: ["segment_id"]; - isOneToOne: false; - referencedRelation: "segments"; - referencedColumns: ["id"]; + foreignKeyName: "segment_rooms_segment_id_fkey" + columns: ["segment_id"] + isOneToOne: false + referencedRelation: "segments" + referencedColumns: ["id"] }, - ]; - }; + ] + } segments: { Row: { - id: string; - name: string; - updated_at: string | null; - }; - Insert: { - id?: string; - name: string; - updated_at?: string | null; - }; - Update: { - id?: string; - name?: string; - updated_at?: string | null; - }; - Relationships: []; - }; + id: string + name: string + updated_at: string | null + } + Insert: { + id?: string + name: string + updated_at?: string | null + } + Update: { + id?: string + name?: string + updated_at?: string | null + } + Relationships: [] + } social_fans: { Row: { - artist_social_id: string; - created_at: string; - fan_social_id: string; - id: string; - latest_engagement: string | null; - latest_engagement_id: string | null; - updated_at: string; - }; - Insert: { - artist_social_id: string; - created_at?: string; - fan_social_id: string; - id?: string; - latest_engagement?: string | null; - latest_engagement_id?: string | null; - updated_at?: string; - }; - Update: { - artist_social_id?: string; - created_at?: string; - fan_social_id?: string; - id?: string; - latest_engagement?: string | null; - latest_engagement_id?: string | null; - updated_at?: string; - }; + artist_social_id: string + created_at: string + fan_social_id: string + id: string + latest_engagement: string | null + latest_engagement_id: string | null + updated_at: string + } + Insert: { + artist_social_id: string + created_at?: string + fan_social_id: string + id?: string + latest_engagement?: string | null + latest_engagement_id?: string | null + updated_at?: string + } + Update: { + artist_social_id?: string + created_at?: string + fan_social_id?: string + id?: string + latest_engagement?: string | null + latest_engagement_id?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "social_fans_artist_social_id_fkey"; - columns: ["artist_social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "social_fans_artist_social_id_fkey" + columns: ["artist_social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, { - foreignKeyName: "social_fans_fan_social_id_fkey"; - columns: ["fan_social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "social_fans_fan_social_id_fkey" + columns: ["fan_social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, { - foreignKeyName: "social_fans_latest_engagement_id_fkey"; - columns: ["latest_engagement_id"]; - isOneToOne: false; - referencedRelation: "post_comments"; - referencedColumns: ["id"]; + foreignKeyName: "social_fans_latest_engagement_id_fkey" + columns: ["latest_engagement_id"] + isOneToOne: false + referencedRelation: "post_comments" + referencedColumns: ["id"] }, - ]; - }; + ] + } social_posts: { Row: { - id: string; - post_id: string | null; - social_id: string | null; - updated_at: string | null; - }; - Insert: { - id?: string; - post_id?: string | null; - social_id?: string | null; - updated_at?: string | null; - }; - Update: { - id?: string; - post_id?: string | null; - social_id?: string | null; - updated_at?: string | null; - }; + id: string + post_id: string | null + social_id: string | null + updated_at: string | null + } + Insert: { + id?: string + post_id?: string | null + social_id?: string | null + updated_at?: string | null + } + Update: { + id?: string + post_id?: string | null + social_id?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "social_posts_post_id_fkey"; - columns: ["post_id"]; - isOneToOne: false; - referencedRelation: "posts"; - referencedColumns: ["id"]; + foreignKeyName: "social_posts_post_id_fkey" + columns: ["post_id"] + isOneToOne: false + referencedRelation: "posts" + referencedColumns: ["id"] }, { - foreignKeyName: "social_posts_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "social_posts_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } social_spotify_albums: { Row: { - album_id: string | null; - id: string; - social_id: string | null; - updated_at: string; - }; - Insert: { - album_id?: string | null; - id?: string; - social_id?: string | null; - updated_at?: string; - }; - Update: { - album_id?: string | null; - id?: string; - social_id?: string | null; - updated_at?: string; - }; + album_id: string | null + id: string + social_id: string | null + updated_at: string + } + Insert: { + album_id?: string | null + id?: string + social_id?: string | null + updated_at?: string + } + Update: { + album_id?: string | null + id?: string + social_id?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "social_spotify_albums_album_id_fkey"; - columns: ["album_id"]; - isOneToOne: false; - referencedRelation: "spotify_albums"; - referencedColumns: ["id"]; + foreignKeyName: "social_spotify_albums_album_id_fkey" + columns: ["album_id"] + isOneToOne: false + referencedRelation: "spotify_albums" + referencedColumns: ["id"] }, { - foreignKeyName: "social_spotify_albums_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "social_spotify_albums_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, - ]; - }; + ] + } social_spotify_tracks: { Row: { - id: string; - social_id: string; - track_id: string | null; - updated_at: string | null; - }; - Insert: { - id?: string; - social_id?: string; - track_id?: string | null; - updated_at?: string | null; - }; - Update: { - id?: string; - social_id?: string; - track_id?: string | null; - updated_at?: string | null; - }; + id: string + social_id: string + track_id: string | null + updated_at: string | null + } + Insert: { + id?: string + social_id?: string + track_id?: string | null + updated_at?: string | null + } + Update: { + id?: string + social_id?: string + track_id?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "social_spotify_tracks_social_id_fkey"; - columns: ["social_id"]; - isOneToOne: false; - referencedRelation: "socials"; - referencedColumns: ["id"]; + foreignKeyName: "social_spotify_tracks_social_id_fkey" + columns: ["social_id"] + isOneToOne: false + referencedRelation: "socials" + referencedColumns: ["id"] }, { - foreignKeyName: "social_spotify_tracks_track_id_fkey"; - columns: ["track_id"]; - isOneToOne: false; - referencedRelation: "spotify_tracks"; - referencedColumns: ["id"]; + foreignKeyName: "social_spotify_tracks_track_id_fkey" + columns: ["track_id"] + isOneToOne: false + referencedRelation: "spotify_tracks" + referencedColumns: ["id"] }, - ]; - }; + ] + } socials: { Row: { - avatar: string | null; - bio: string | null; - followerCount: number | null; - followingCount: number | null; - id: string; - profile_url: string; - region: string | null; - updated_at: string; - username: string; - }; - Insert: { - avatar?: string | null; - bio?: string | null; - followerCount?: number | null; - followingCount?: number | null; - id?: string; - profile_url: string; - region?: string | null; - updated_at?: string; - username: string; - }; - Update: { - avatar?: string | null; - bio?: string | null; - followerCount?: number | null; - followingCount?: number | null; - id?: string; - profile_url?: string; - region?: string | null; - updated_at?: string; - username?: string; - }; - Relationships: []; - }; + avatar: string | null + bio: string | null + followerCount: number | null + followingCount: number | null + id: string + profile_url: string + region: string | null + updated_at: string + username: string + } + Insert: { + avatar?: string | null + bio?: string | null + followerCount?: number | null + followingCount?: number | null + id?: string + profile_url: string + region?: string | null + updated_at?: string + username: string + } + Update: { + avatar?: string | null + bio?: string | null + followerCount?: number | null + followingCount?: number | null + id?: string + profile_url?: string + region?: string | null + updated_at?: string + username?: string + } + Relationships: [] + } song_artists: { Row: { - artist: string; - created_at: string; - id: string; - song: string; - updated_at: string; - }; - Insert: { - artist: string; - created_at?: string; - id?: string; - song: string; - updated_at?: string; - }; - Update: { - artist?: string; - created_at?: string; - id?: string; - song?: string; - updated_at?: string; - }; + artist: string + created_at: string + id: string + song: string + updated_at: string + } + Insert: { + artist: string + created_at?: string + id?: string + song: string + updated_at?: string + } + Update: { + artist?: string + created_at?: string + id?: string + song?: string + updated_at?: string + } Relationships: [ { - foreignKeyName: "song_artists_artist_fkey"; - columns: ["artist"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "song_artists_artist_fkey" + columns: ["artist"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] }, { - foreignKeyName: "song_artists_song_fkey"; - columns: ["song"]; - isOneToOne: false; - referencedRelation: "songs"; - referencedColumns: ["isrc"]; + foreignKeyName: "song_artists_song_fkey" + columns: ["song"] + isOneToOne: false + referencedRelation: "songs" + referencedColumns: ["isrc"] }, - ]; - }; + ] + } songs: { Row: { - album: string | null; - isrc: string; - name: string | null; - notes: string | null; - updated_at: string; - }; - Insert: { - album?: string | null; - isrc: string; - name?: string | null; - notes?: string | null; - updated_at?: string; - }; - Update: { - album?: string | null; - isrc?: string; - name?: string | null; - notes?: string | null; - updated_at?: string; - }; - Relationships: []; - }; + album: string | null + isrc: string + name: string | null + notes: string | null + updated_at: string + } + Insert: { + album?: string | null + isrc: string + name?: string | null + notes?: string | null + updated_at?: string + } + Update: { + album?: string | null + isrc?: string + name?: string | null + notes?: string | null + updated_at?: string + } + Relationships: [] + } spotify: { Row: { - clientId: string | null; - country: string | null; - display_name: string | null; - email: string | null; - "explicit_content.filter_enabled": string | null; - "explicit_content.filter_locked": string | null; - "external_urls.spotify": Json | null; - fanId: string | null; - "fanId.country": string | null; - "fanId.display_name": string | null; - "fanId.email": string | null; - "fanId.explicit_content.filter_enabled": string | null; - "fanId.explicit_content.filter_locked": string | null; - "fanId.external_urls.spotify": string | null; - "fanId.followers.total": string | null; - "fanId.href": string | null; - "fanId.id": string | null; - "fanId.images": string | null; - "fanId.isNewFan": string | null; - "fanId.playlist": string | null; - "fanId.presavedData.clientId": string | null; - "fanId.presavedData.country": string | null; - "fanId.presavedData.display_name": string | null; - "fanId.presavedData.email": string | null; - "fanId.presavedData.explicit_content.filter_enabled": string | null; - "fanId.presavedData.explicit_content.filter_locked": string | null; - "fanId.presavedData.external_urls.spotify": string | null; - "fanId.presavedData.followers.total": string | null; - "fanId.presavedData.href": string | null; - "fanId.presavedData.id": string | null; - "fanId.presavedData.images": string | null; - "fanId.presavedData.playlist": string | null; - "fanId.presavedData.product": string | null; - "fanId.presavedData.recentlyPlayed": string | null; - "fanId.presavedData.timestamp": string | null; - "fanId.presavedData.type": string | null; - "fanId.presavedData.uri": string | null; - "fanId.product": string | null; - "fanId.timestamp": string | null; - "fanId.type": string | null; - "fanId.uri": string | null; - "followers.total": Json | null; - game: string | null; - href: string | null; - id: string | null; - images: Json | null; - playlist: Json | null; - product: string | null; - syncId: string | null; - timestamp: string | null; - type: string | null; - uri: string | null; - }; - Insert: { - clientId?: string | null; - country?: string | null; - display_name?: string | null; - email?: string | null; - "explicit_content.filter_enabled"?: string | null; - "explicit_content.filter_locked"?: string | null; - "external_urls.spotify"?: Json | null; - fanId?: string | null; - "fanId.country"?: string | null; - "fanId.display_name"?: string | null; - "fanId.email"?: string | null; - "fanId.explicit_content.filter_enabled"?: string | null; - "fanId.explicit_content.filter_locked"?: string | null; - "fanId.external_urls.spotify"?: string | null; - "fanId.followers.total"?: string | null; - "fanId.href"?: string | null; - "fanId.id"?: string | null; - "fanId.images"?: string | null; - "fanId.isNewFan"?: string | null; - "fanId.playlist"?: string | null; - "fanId.presavedData.clientId"?: string | null; - "fanId.presavedData.country"?: string | null; - "fanId.presavedData.display_name"?: string | null; - "fanId.presavedData.email"?: string | null; - "fanId.presavedData.explicit_content.filter_enabled"?: string | null; - "fanId.presavedData.explicit_content.filter_locked"?: string | null; - "fanId.presavedData.external_urls.spotify"?: string | null; - "fanId.presavedData.followers.total"?: string | null; - "fanId.presavedData.href"?: string | null; - "fanId.presavedData.id"?: string | null; - "fanId.presavedData.images"?: string | null; - "fanId.presavedData.playlist"?: string | null; - "fanId.presavedData.product"?: string | null; - "fanId.presavedData.recentlyPlayed"?: string | null; - "fanId.presavedData.timestamp"?: string | null; - "fanId.presavedData.type"?: string | null; - "fanId.presavedData.uri"?: string | null; - "fanId.product"?: string | null; - "fanId.timestamp"?: string | null; - "fanId.type"?: string | null; - "fanId.uri"?: string | null; - "followers.total"?: Json | null; - game?: string | null; - href?: string | null; - id?: string | null; - images?: Json | null; - playlist?: Json | null; - product?: string | null; - syncId?: string | null; - timestamp?: string | null; - type?: string | null; - uri?: string | null; - }; - Update: { - clientId?: string | null; - country?: string | null; - display_name?: string | null; - email?: string | null; - "explicit_content.filter_enabled"?: string | null; - "explicit_content.filter_locked"?: string | null; - "external_urls.spotify"?: Json | null; - fanId?: string | null; - "fanId.country"?: string | null; - "fanId.display_name"?: string | null; - "fanId.email"?: string | null; - "fanId.explicit_content.filter_enabled"?: string | null; - "fanId.explicit_content.filter_locked"?: string | null; - "fanId.external_urls.spotify"?: string | null; - "fanId.followers.total"?: string | null; - "fanId.href"?: string | null; - "fanId.id"?: string | null; - "fanId.images"?: string | null; - "fanId.isNewFan"?: string | null; - "fanId.playlist"?: string | null; - "fanId.presavedData.clientId"?: string | null; - "fanId.presavedData.country"?: string | null; - "fanId.presavedData.display_name"?: string | null; - "fanId.presavedData.email"?: string | null; - "fanId.presavedData.explicit_content.filter_enabled"?: string | null; - "fanId.presavedData.explicit_content.filter_locked"?: string | null; - "fanId.presavedData.external_urls.spotify"?: string | null; - "fanId.presavedData.followers.total"?: string | null; - "fanId.presavedData.href"?: string | null; - "fanId.presavedData.id"?: string | null; - "fanId.presavedData.images"?: string | null; - "fanId.presavedData.playlist"?: string | null; - "fanId.presavedData.product"?: string | null; - "fanId.presavedData.recentlyPlayed"?: string | null; - "fanId.presavedData.timestamp"?: string | null; - "fanId.presavedData.type"?: string | null; - "fanId.presavedData.uri"?: string | null; - "fanId.product"?: string | null; - "fanId.timestamp"?: string | null; - "fanId.type"?: string | null; - "fanId.uri"?: string | null; - "followers.total"?: Json | null; - game?: string | null; - href?: string | null; - id?: string | null; - images?: Json | null; - playlist?: Json | null; - product?: string | null; - syncId?: string | null; - timestamp?: string | null; - type?: string | null; - uri?: string | null; - }; - Relationships: []; - }; + clientId: string | null + country: string | null + display_name: string | null + email: string | null + "explicit_content.filter_enabled": string | null + "explicit_content.filter_locked": string | null + "external_urls.spotify": Json | null + fanId: string | null + "fanId.country": string | null + "fanId.display_name": string | null + "fanId.email": string | null + "fanId.explicit_content.filter_enabled": string | null + "fanId.explicit_content.filter_locked": string | null + "fanId.external_urls.spotify": string | null + "fanId.followers.total": string | null + "fanId.href": string | null + "fanId.id": string | null + "fanId.images": string | null + "fanId.isNewFan": string | null + "fanId.playlist": string | null + "fanId.presavedData.clientId": string | null + "fanId.presavedData.country": string | null + "fanId.presavedData.display_name": string | null + "fanId.presavedData.email": string | null + "fanId.presavedData.explicit_content.filter_enabled": string | null + "fanId.presavedData.explicit_content.filter_locked": string | null + "fanId.presavedData.external_urls.spotify": string | null + "fanId.presavedData.followers.total": string | null + "fanId.presavedData.href": string | null + "fanId.presavedData.id": string | null + "fanId.presavedData.images": string | null + "fanId.presavedData.playlist": string | null + "fanId.presavedData.product": string | null + "fanId.presavedData.recentlyPlayed": string | null + "fanId.presavedData.timestamp": string | null + "fanId.presavedData.type": string | null + "fanId.presavedData.uri": string | null + "fanId.product": string | null + "fanId.timestamp": string | null + "fanId.type": string | null + "fanId.uri": string | null + "followers.total": Json | null + game: string | null + href: string | null + id: string | null + images: Json | null + playlist: Json | null + product: string | null + syncId: string | null + timestamp: string | null + type: string | null + uri: string | null + } + Insert: { + clientId?: string | null + country?: string | null + display_name?: string | null + email?: string | null + "explicit_content.filter_enabled"?: string | null + "explicit_content.filter_locked"?: string | null + "external_urls.spotify"?: Json | null + fanId?: string | null + "fanId.country"?: string | null + "fanId.display_name"?: string | null + "fanId.email"?: string | null + "fanId.explicit_content.filter_enabled"?: string | null + "fanId.explicit_content.filter_locked"?: string | null + "fanId.external_urls.spotify"?: string | null + "fanId.followers.total"?: string | null + "fanId.href"?: string | null + "fanId.id"?: string | null + "fanId.images"?: string | null + "fanId.isNewFan"?: string | null + "fanId.playlist"?: string | null + "fanId.presavedData.clientId"?: string | null + "fanId.presavedData.country"?: string | null + "fanId.presavedData.display_name"?: string | null + "fanId.presavedData.email"?: string | null + "fanId.presavedData.explicit_content.filter_enabled"?: string | null + "fanId.presavedData.explicit_content.filter_locked"?: string | null + "fanId.presavedData.external_urls.spotify"?: string | null + "fanId.presavedData.followers.total"?: string | null + "fanId.presavedData.href"?: string | null + "fanId.presavedData.id"?: string | null + "fanId.presavedData.images"?: string | null + "fanId.presavedData.playlist"?: string | null + "fanId.presavedData.product"?: string | null + "fanId.presavedData.recentlyPlayed"?: string | null + "fanId.presavedData.timestamp"?: string | null + "fanId.presavedData.type"?: string | null + "fanId.presavedData.uri"?: string | null + "fanId.product"?: string | null + "fanId.timestamp"?: string | null + "fanId.type"?: string | null + "fanId.uri"?: string | null + "followers.total"?: Json | null + game?: string | null + href?: string | null + id?: string | null + images?: Json | null + playlist?: Json | null + product?: string | null + syncId?: string | null + timestamp?: string | null + type?: string | null + uri?: string | null + } + Update: { + clientId?: string | null + country?: string | null + display_name?: string | null + email?: string | null + "explicit_content.filter_enabled"?: string | null + "explicit_content.filter_locked"?: string | null + "external_urls.spotify"?: Json | null + fanId?: string | null + "fanId.country"?: string | null + "fanId.display_name"?: string | null + "fanId.email"?: string | null + "fanId.explicit_content.filter_enabled"?: string | null + "fanId.explicit_content.filter_locked"?: string | null + "fanId.external_urls.spotify"?: string | null + "fanId.followers.total"?: string | null + "fanId.href"?: string | null + "fanId.id"?: string | null + "fanId.images"?: string | null + "fanId.isNewFan"?: string | null + "fanId.playlist"?: string | null + "fanId.presavedData.clientId"?: string | null + "fanId.presavedData.country"?: string | null + "fanId.presavedData.display_name"?: string | null + "fanId.presavedData.email"?: string | null + "fanId.presavedData.explicit_content.filter_enabled"?: string | null + "fanId.presavedData.explicit_content.filter_locked"?: string | null + "fanId.presavedData.external_urls.spotify"?: string | null + "fanId.presavedData.followers.total"?: string | null + "fanId.presavedData.href"?: string | null + "fanId.presavedData.id"?: string | null + "fanId.presavedData.images"?: string | null + "fanId.presavedData.playlist"?: string | null + "fanId.presavedData.product"?: string | null + "fanId.presavedData.recentlyPlayed"?: string | null + "fanId.presavedData.timestamp"?: string | null + "fanId.presavedData.type"?: string | null + "fanId.presavedData.uri"?: string | null + "fanId.product"?: string | null + "fanId.timestamp"?: string | null + "fanId.type"?: string | null + "fanId.uri"?: string | null + "followers.total"?: Json | null + game?: string | null + href?: string | null + id?: string | null + images?: Json | null + playlist?: Json | null + product?: string | null + syncId?: string | null + timestamp?: string | null + type?: string | null + uri?: string | null + } + Relationships: [] + } spotify_albums: { Row: { - id: string; - name: string | null; - release_date: string | null; - updated_at: string; - uri: string; - }; - Insert: { - id?: string; - name?: string | null; - release_date?: string | null; - updated_at?: string; - uri: string; - }; - Update: { - id?: string; - name?: string | null; - release_date?: string | null; - updated_at?: string; - uri?: string; - }; - Relationships: []; - }; + id: string + name: string | null + release_date: string | null + updated_at: string + uri: string + } + Insert: { + id?: string + name?: string | null + release_date?: string | null + updated_at?: string + uri: string + } + Update: { + id?: string + name?: string | null + release_date?: string | null + updated_at?: string + uri?: string + } + Relationships: [] + } spotify_analytics_albums: { Row: { - analysis_id: string | null; - artist_name: string | null; - created_at: string; - id: string; - name: string | null; - release_date: number | null; - uri: string | null; - }; - Insert: { - analysis_id?: string | null; - artist_name?: string | null; - created_at?: string; - id?: string; - name?: string | null; - release_date?: number | null; - uri?: string | null; - }; - Update: { - analysis_id?: string | null; - artist_name?: string | null; - created_at?: string; - id?: string; - name?: string | null; - release_date?: number | null; - uri?: string | null; - }; + analysis_id: string | null + artist_name: string | null + created_at: string + id: string + name: string | null + release_date: number | null + uri: string | null + } + Insert: { + analysis_id?: string | null + artist_name?: string | null + created_at?: string + id?: string + name?: string | null + release_date?: number | null + uri?: string | null + } + Update: { + analysis_id?: string | null + artist_name?: string | null + created_at?: string + id?: string + name?: string | null + release_date?: number | null + uri?: string | null + } Relationships: [ { - foreignKeyName: "spotify_analytics_albums_analysis_id_fkey"; - columns: ["analysis_id"]; - isOneToOne: false; - referencedRelation: "funnel_analytics"; - referencedColumns: ["id"]; + foreignKeyName: "spotify_analytics_albums_analysis_id_fkey" + columns: ["analysis_id"] + isOneToOne: false + referencedRelation: "funnel_analytics" + referencedColumns: ["id"] }, - ]; - }; + ] + } spotify_analytics_tracks: { Row: { - analysis_id: string | null; - artist_name: string | null; - created_at: string; - id: string; - name: string | null; - popularity: number | null; - uri: string | null; - }; - Insert: { - analysis_id?: string | null; - artist_name?: string | null; - created_at?: string; - id?: string; - name?: string | null; - popularity?: number | null; - uri?: string | null; - }; - Update: { - analysis_id?: string | null; - artist_name?: string | null; - created_at?: string; - id?: string; - name?: string | null; - popularity?: number | null; - uri?: string | null; - }; + analysis_id: string | null + artist_name: string | null + created_at: string + id: string + name: string | null + popularity: number | null + uri: string | null + } + Insert: { + analysis_id?: string | null + artist_name?: string | null + created_at?: string + id?: string + name?: string | null + popularity?: number | null + uri?: string | null + } + Update: { + analysis_id?: string | null + artist_name?: string | null + created_at?: string + id?: string + name?: string | null + popularity?: number | null + uri?: string | null + } Relationships: [ { - foreignKeyName: "spotify_analytics_tracks_analysis_id_fkey"; - columns: ["analysis_id"]; - isOneToOne: false; - referencedRelation: "funnel_analytics"; - referencedColumns: ["id"]; + foreignKeyName: "spotify_analytics_tracks_analysis_id_fkey" + columns: ["analysis_id"] + isOneToOne: false + referencedRelation: "funnel_analytics" + referencedColumns: ["id"] }, - ]; - }; + ] + } spotify_login_button_clicked: { Row: { - campaignId: string | null; - clientId: string | null; - fanId: string | null; - game: string | null; - id: string | null; - timestamp: number | null; - }; - Insert: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; - Update: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string | null; - timestamp?: number | null; - }; + campaignId: string | null + clientId: string | null + fanId: string | null + game: string | null + id: string | null + timestamp: number | null + } + Insert: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: number | null + } + Update: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string | null + timestamp?: number | null + } Relationships: [ { - foreignKeyName: "spotify_login_button_clicked_campaignId_fkey"; - columns: ["campaignId"]; - isOneToOne: false; - referencedRelation: "campaigns"; - referencedColumns: ["id"]; + foreignKeyName: "spotify_login_button_clicked_campaignId_fkey" + columns: ["campaignId"] + isOneToOne: false + referencedRelation: "campaigns" + referencedColumns: ["id"] }, - ]; - }; + ] + } spotify_play_button_clicked: { Row: { - campaignId: string | null; - clientId: string | null; - fanId: string | null; - game: string | null; - id: string; - isPremium: boolean | null; - timestamp: number | null; - }; - Insert: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string; - isPremium?: boolean | null; - timestamp?: number | null; - }; - Update: { - campaignId?: string | null; - clientId?: string | null; - fanId?: string | null; - game?: string | null; - id?: string; - isPremium?: boolean | null; - timestamp?: number | null; - }; + campaignId: string | null + clientId: string | null + fanId: string | null + game: string | null + id: string + isPremium: boolean | null + timestamp: number | null + } + Insert: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string + isPremium?: boolean | null + timestamp?: number | null + } + Update: { + campaignId?: string | null + clientId?: string | null + fanId?: string | null + game?: string | null + id?: string + isPremium?: boolean | null + timestamp?: number | null + } Relationships: [ { - foreignKeyName: "spotify_play_button_clicked_campaignId_fkey"; - columns: ["campaignId"]; - isOneToOne: false; - referencedRelation: "campaigns"; - referencedColumns: ["id"]; + foreignKeyName: "spotify_play_button_clicked_campaignId_fkey" + columns: ["campaignId"] + isOneToOne: false + referencedRelation: "campaigns" + referencedColumns: ["id"] }, { - foreignKeyName: "spotify_play_button_clicked_fanId_fkey"; - columns: ["fanId"]; - isOneToOne: false; - referencedRelation: "fans"; - referencedColumns: ["id"]; + foreignKeyName: "spotify_play_button_clicked_fanId_fkey" + columns: ["fanId"] + isOneToOne: false + referencedRelation: "fans" + referencedColumns: ["id"] }, - ]; - }; + ] + } spotify_tracks: { Row: { - id: string; - name: string | null; - popularity: number | null; - updated_at: string; - uri: string; - }; - Insert: { - id?: string; - name?: string | null; - popularity?: number | null; - updated_at?: string; - uri: string; - }; - Update: { - id?: string; - name?: string | null; - popularity?: number | null; - updated_at?: string; - uri?: string; - }; - Relationships: []; - }; + id: string + name: string | null + popularity: number | null + updated_at: string + uri: string + } + Insert: { + id?: string + name?: string | null + popularity?: number | null + updated_at?: string + uri: string + } + Update: { + id?: string + name?: string | null + popularity?: number | null + updated_at?: string + uri?: string + } + Relationships: [] + } subscription_items: { Row: { - created_at: string; - id: string; - interval: string; - interval_count: number; - price_amount: number | null; - product_id: string; - quantity: number; - subscription_id: string; - type: Database["public"]["Enums"]["subscription_item_type"]; - updated_at: string; - variant_id: string; - }; - Insert: { - created_at?: string; - id: string; - interval: string; - interval_count: number; - price_amount?: number | null; - product_id: string; - quantity?: number; - subscription_id: string; - type: Database["public"]["Enums"]["subscription_item_type"]; - updated_at?: string; - variant_id: string; - }; - Update: { - created_at?: string; - id?: string; - interval?: string; - interval_count?: number; - price_amount?: number | null; - product_id?: string; - quantity?: number; - subscription_id?: string; - type?: Database["public"]["Enums"]["subscription_item_type"]; - updated_at?: string; - variant_id?: string; - }; + created_at: string + id: string + interval: string + interval_count: number + price_amount: number | null + product_id: string + quantity: number + subscription_id: string + type: Database["public"]["Enums"]["subscription_item_type"] + updated_at: string + variant_id: string + } + Insert: { + created_at?: string + id: string + interval: string + interval_count: number + price_amount?: number | null + product_id: string + quantity?: number + subscription_id: string + type: Database["public"]["Enums"]["subscription_item_type"] + updated_at?: string + variant_id: string + } + Update: { + created_at?: string + id?: string + interval?: string + interval_count?: number + price_amount?: number | null + product_id?: string + quantity?: number + subscription_id?: string + type?: Database["public"]["Enums"]["subscription_item_type"] + updated_at?: string + variant_id?: string + } Relationships: [ { - foreignKeyName: "subscription_items_subscription_id_fkey"; - columns: ["subscription_id"]; - isOneToOne: false; - referencedRelation: "subscriptions"; - referencedColumns: ["id"]; + foreignKeyName: "subscription_items_subscription_id_fkey" + columns: ["subscription_id"] + isOneToOne: false + referencedRelation: "subscriptions" + referencedColumns: ["id"] }, - ]; - }; + ] + } subscriptions: { Row: { - account_id: string; - active: boolean; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - cancel_at_period_end: boolean; - created_at: string; - currency: string; - id: string; - period_ends_at: string; - period_starts_at: string; - status: Database["public"]["Enums"]["subscription_status"]; - trial_ends_at: string | null; - trial_starts_at: string | null; - updated_at: string; - }; - Insert: { - account_id: string; - active: boolean; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - cancel_at_period_end: boolean; - created_at?: string; - currency: string; - id: string; - period_ends_at: string; - period_starts_at: string; - status: Database["public"]["Enums"]["subscription_status"]; - trial_ends_at?: string | null; - trial_starts_at?: string | null; - updated_at?: string; - }; - Update: { - account_id?: string; - active?: boolean; - billing_customer_id?: number; - billing_provider?: Database["public"]["Enums"]["billing_provider"]; - cancel_at_period_end?: boolean; - created_at?: string; - currency?: string; - id?: string; - period_ends_at?: string; - period_starts_at?: string; - status?: Database["public"]["Enums"]["subscription_status"]; - trial_ends_at?: string | null; - trial_starts_at?: string | null; - updated_at?: string; - }; + account_id: string + active: boolean + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + cancel_at_period_end: boolean + created_at: string + currency: string + id: string + period_ends_at: string + period_starts_at: string + status: Database["public"]["Enums"]["subscription_status"] + trial_ends_at: string | null + trial_starts_at: string | null + updated_at: string + } + Insert: { + account_id: string + active: boolean + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + cancel_at_period_end: boolean + created_at?: string + currency: string + id: string + period_ends_at: string + period_starts_at: string + status: Database["public"]["Enums"]["subscription_status"] + trial_ends_at?: string | null + trial_starts_at?: string | null + updated_at?: string + } + Update: { + account_id?: string + active?: boolean + billing_customer_id?: number + billing_provider?: Database["public"]["Enums"]["billing_provider"] + cancel_at_period_end?: boolean + created_at?: string + currency?: string + id?: string + period_ends_at?: string + period_starts_at?: string + status?: Database["public"]["Enums"]["subscription_status"] + trial_ends_at?: string | null + trial_starts_at?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "subscriptions_billing_customer_id_fkey"; - columns: ["billing_customer_id"]; - isOneToOne: false; - referencedRelation: "billing_customers"; - referencedColumns: ["id"]; + foreignKeyName: "subscriptions_billing_customer_id_fkey" + columns: ["billing_customer_id"] + isOneToOne: false + referencedRelation: "billing_customers" + referencedColumns: ["id"] }, - ]; - }; + ] + } tasks: { Row: { - account_id: string; - created_at: string; - description: string | null; - done: boolean; - id: string; - title: string; - updated_at: string; - }; - Insert: { - account_id: string; - created_at?: string; - description?: string | null; - done?: boolean; - id?: string; - title: string; - updated_at?: string; - }; - Update: { - account_id?: string; - created_at?: string; - description?: string | null; - done?: boolean; - id?: string; - title?: string; - updated_at?: string; - }; - Relationships: []; - }; + account_id: string + created_at: string + description: string | null + done: boolean + id: string + title: string + updated_at: string + } + Insert: { + account_id: string + created_at?: string + description?: string | null + done?: boolean + id?: string + title: string + updated_at?: string + } + Update: { + account_id?: string + created_at?: string + description?: string | null + done?: boolean + id?: string + title?: string + updated_at?: string + } + Relationships: [] + } test_emails: { Row: { - created_at: string; - email: string | null; - id: number; - }; - Insert: { - created_at?: string; - email?: string | null; - id?: number; - }; - Update: { - created_at?: string; - email?: string | null; - id?: number; - }; - Relationships: []; - }; + created_at: string + email: string | null + id: number + } + Insert: { + created_at?: string + email?: string | null + id?: number + } + Update: { + created_at?: string + email?: string | null + id?: number + } + Relationships: [] + } youtube_tokens: { Row: { - access_token: string; - artist_account_id: string; - created_at: string; - expires_at: string; - id: string; - refresh_token: string | null; - updated_at: string; - }; - Insert: { - access_token: string; - artist_account_id: string; - created_at?: string; - expires_at: string; - id?: string; - refresh_token?: string | null; - updated_at?: string; - }; - Update: { - access_token?: string; - artist_account_id?: string; - created_at?: string; - expires_at?: string; - id?: string; - refresh_token?: string | null; - updated_at?: string; - }; + access_token: string + artist_account_id: string + created_at: string + expires_at: string + id: string + refresh_token: string | null + updated_at: string + } + Insert: { + access_token: string + artist_account_id: string + created_at?: string + expires_at: string + id?: string + refresh_token?: string | null + updated_at?: string + } + Update: { + access_token?: string + artist_account_id?: string + created_at?: string + expires_at?: string + id?: string + refresh_token?: string | null + updated_at?: string + } Relationships: [ { - foreignKeyName: "youtube_tokens_artist_account_id_fkey"; - columns: ["artist_account_id"]; - isOneToOne: true; - referencedRelation: "accounts"; - referencedColumns: ["id"]; + foreignKeyName: "youtube_tokens_artist_account_id_fkey" + columns: ["artist_account_id"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["id"] }, - ]; - }; - }; + ] + } + } Views: { - [_ in never]: never; - }; + [_ in never]: never + } Functions: { accept_invitation: { - Args: { token: string; user_id: string }; - Returns: string; - }; + Args: { token: string; user_id: string } + Returns: string + } add_invitations_to_account: { Args: { - account_slug: string; - invitations: Database["public"]["CompositeTypes"]["invitation"][]; - }; - Returns: Database["public"]["Tables"]["invitations"]["Row"][]; - }; + account_slug: string + invitations: Database["public"]["CompositeTypes"]["invitation"][] + } + Returns: Database["public"]["Tables"]["invitations"]["Row"][] + } can_action_account_member: { - Args: { target_team_account_id: string; target_user_id: string }; - Returns: boolean; - }; + Args: { target_team_account_id: string; target_user_id: string } + Returns: boolean + } count_reports_by_day: { - Args: { end_date: string; start_date: string }; + Args: { end_date: string; start_date: string } Returns: { - count: number; - date_key: string; - }[]; - }; + count: number + date_key: string + }[] + } count_reports_by_month: { - Args: { end_date: string; start_date: string }; + Args: { end_date: string; start_date: string } Returns: { - count: number; - date_key: string; - }[]; - }; + count: number + date_key: string + }[] + } count_reports_by_week: { - Args: { end_date: string; start_date: string }; + Args: { end_date: string; start_date: string } Returns: { - count: number; - date_key: string; - }[]; - }; + count: number + date_key: string + }[] + } create_invitation: { - Args: { account_id: string; email: string; role: string }; + Args: { account_id: string; email: string; role: string } Returns: { - account_id: string; - created_at: string; - email: string; - expires_at: string; - id: number; - invite_token: string; - invited_by: string; - role: string; - updated_at: string; - }; + account_id: string + created_at: string + email: string + expires_at: string + id: number + invite_token: string + invited_by: string + role: string + updated_at: string + } SetofOptions: { - from: "*"; - to: "invitations"; - isOneToOne: true; - isSetofReturn: false; - }; - }; + from: "*" + to: "invitations" + isOneToOne: true + isSetofReturn: false + } + } deduct_credits: { - Args: { account_id: string; amount: number }; - Returns: undefined; - }; - extract_domain: { Args: { email: string }; Returns: string }; + Args: { account_id: string; amount: number } + Returns: undefined + } + extract_domain: { Args: { email: string }; Returns: string } get_account_invitations: { - Args: { account_slug: string }; + Args: { account_slug: string } Returns: { - account_id: string; - created_at: string; - email: string; - expires_at: string; - id: number; - invited_by: string; - inviter_email: string; - inviter_name: string; - role: string; - updated_at: string; - }[]; - }; + account_id: string + created_at: string + email: string + expires_at: string + id: number + invited_by: string + inviter_email: string + inviter_name: string + role: string + updated_at: string + }[] + } get_account_members: { - Args: { account_slug: string }; + Args: { account_slug: string } Returns: { - account_id: string; - created_at: string; - email: string; - id: string; - name: string; - picture_url: string; - primary_owner_user_id: string; - role: string; - role_hierarchy_level: number; - updated_at: string; - user_id: string; - }[]; - }; + account_id: string + created_at: string + email: string + id: string + name: string + picture_url: string + primary_owner_user_id: string + role: string + role_hierarchy_level: number + updated_at: string + user_id: string + }[] + } get_campaign: | { Args: { clientid: string }; Returns: Json } | { - Args: { artistid: string; campaignid: string; email: string }; - Returns: Json; - }; + Args: { artistid: string; campaignid: string; email: string } + Returns: Json + } get_campaign_fans: { - Args: { artistid: string; email: string }; - Returns: Json; - }; - get_config: { Args: never; Returns: Json }; + Args: { artistid: string; email: string } + Returns: Json + } + get_config: { Args: never; Returns: Json } get_fans_listening_top_songs: { - Args: { artistid: string; email: string }; - Returns: Json; - }; + Args: { artistid: string; email: string } + Returns: Json + } get_message_counts_by_user: | { - Args: { start_date: string }; + Args: { start_date: string } Returns: { - account_email: string; - message_count: number; - }[]; + account_email: string + message_count: number + }[] } | { - Args: { end_date: string; start_date: string }; + Args: { end_date: string; start_date: string } Returns: { - account_email: string; - message_count: number; - }[]; - }; + account_email: string + message_count: number + }[] + } get_rooms_created_by_user: { - Args: { start_date: string }; + Args: { start_date: string } Returns: { - account_email: string; - rooms_created: number; - }[]; - }; + account_email: string + rooms_created: number + }[] + } get_segment_reports_by_user: { - Args: { start_date: string }; + Args: { start_date: string } Returns: { - email: string; - segment_report_count: number; - }[]; - }; - get_upper_system_role: { Args: never; Returns: string }; + email: string + segment_report_count: number + }[] + } + get_upper_system_role: { Args: never; Returns: string } has_active_subscription: { - Args: { target_account_id: string }; - Returns: boolean; - }; - has_credits: { Args: { account_id: string }; Returns: boolean }; + Args: { target_account_id: string } + Returns: boolean + } + has_credits: { Args: { account_id: string }; Returns: boolean } has_more_elevated_role: { Args: { - role_name: string; - target_account_id: string; - target_user_id: string; - }; - Returns: boolean; - }; + role_name: string + target_account_id: string + target_user_id: string + } + Returns: boolean + } has_permission: { Args: { - account_id: string; - permission_name: Database["public"]["Enums"]["app_permissions"]; - user_id: string; - }; - Returns: boolean; - }; + account_id: string + permission_name: Database["public"]["Enums"]["app_permissions"] + user_id: string + } + Returns: boolean + } has_role_on_account: { - Args: { account_id: string; account_role?: string }; - Returns: boolean; - }; + Args: { account_id: string; account_role?: string } + Returns: boolean + } has_same_role_hierarchy_level: { Args: { - role_name: string; - target_account_id: string; - target_user_id: string; - }; - Returns: boolean; - }; - is_account_owner: { Args: { account_id: string }; Returns: boolean }; + role_name: string + target_account_id: string + target_user_id: string + } + Returns: boolean + } + is_account_owner: { Args: { account_id: string }; Returns: boolean } is_account_team_member: { - Args: { target_account_id: string }; - Returns: boolean; - }; - is_set: { Args: { field_name: string }; Returns: boolean }; + Args: { target_account_id: string } + Returns: boolean + } + is_set: { Args: { field_name: string }; Returns: boolean } is_team_member: { - Args: { account_id: string; user_id: string }; - Returns: boolean; - }; + Args: { account_id: string; user_id: string } + Returns: boolean + } team_account_workspace: { - Args: { account_slug: string }; + Args: { account_slug: string } Returns: { - id: string; - name: string; - permissions: Database["public"]["Enums"]["app_permissions"][]; - picture_url: string; - primary_owner_user_id: string; - role: string; - role_hierarchy_level: number; - slug: string; - subscription_status: Database["public"]["Enums"]["subscription_status"]; - }[]; - }; + id: string + name: string + permissions: Database["public"]["Enums"]["app_permissions"][] + picture_url: string + primary_owner_user_id: string + role: string + role_hierarchy_level: number + slug: string + subscription_status: Database["public"]["Enums"]["subscription_status"] + }[] + } transfer_team_account_ownership: { - Args: { new_owner_id: string; target_account_id: string }; - Returns: undefined; - }; + Args: { new_owner_id: string; target_account_id: string } + Returns: undefined + } upsert_order: { Args: { - billing_provider: Database["public"]["Enums"]["billing_provider"]; - currency: string; - line_items: Json; - status: Database["public"]["Enums"]["payment_status"]; - target_account_id: string; - target_customer_id: string; - target_order_id: string; - total_amount: number; - }; + billing_provider: Database["public"]["Enums"]["billing_provider"] + currency: string + line_items: Json + status: Database["public"]["Enums"]["payment_status"] + target_account_id: string + target_customer_id: string + target_order_id: string + total_amount: number + } Returns: { - account_id: string; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - created_at: string; - currency: string; - id: string; - status: Database["public"]["Enums"]["payment_status"]; - total_amount: number; - updated_at: string; - }; + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at: string + currency: string + id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at: string + } SetofOptions: { - from: "*"; - to: "orders"; - isOneToOne: true; - isSetofReturn: false; - }; - }; + from: "*" + to: "orders" + isOneToOne: true + isSetofReturn: false + } + } upsert_subscription: { Args: { - active: boolean; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - cancel_at_period_end: boolean; - currency: string; - line_items: Json; - period_ends_at: string; - period_starts_at: string; - status: Database["public"]["Enums"]["subscription_status"]; - target_account_id: string; - target_customer_id: string; - target_subscription_id: string; - trial_ends_at?: string; - trial_starts_at?: string; - }; + active: boolean + billing_provider: Database["public"]["Enums"]["billing_provider"] + cancel_at_period_end: boolean + currency: string + line_items: Json + period_ends_at: string + period_starts_at: string + status: Database["public"]["Enums"]["subscription_status"] + target_account_id: string + target_customer_id: string + target_subscription_id: string + trial_ends_at?: string + trial_starts_at?: string + } Returns: { - account_id: string; - active: boolean; - billing_customer_id: number; - billing_provider: Database["public"]["Enums"]["billing_provider"]; - cancel_at_period_end: boolean; - created_at: string; - currency: string; - id: string; - period_ends_at: string; - period_starts_at: string; - status: Database["public"]["Enums"]["subscription_status"]; - trial_ends_at: string | null; - trial_starts_at: string | null; - updated_at: string; - }; + account_id: string + active: boolean + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + cancel_at_period_end: boolean + created_at: string + currency: string + id: string + period_ends_at: string + period_starts_at: string + status: Database["public"]["Enums"]["subscription_status"] + trial_ends_at: string | null + trial_starts_at: string | null + updated_at: string + } SetofOptions: { - from: "*"; - to: "subscriptions"; - isOneToOne: true; - isSetofReturn: false; - }; - }; - }; + from: "*" + to: "subscriptions" + isOneToOne: true + isSetofReturn: false + } + } + } Enums: { app_permissions: | "roles.manage" @@ -3811,14 +3872,20 @@ export type Database = { | "members.manage" | "invites.manage" | "tasks.write" - | "tasks.delete"; - billing_provider: "stripe" | "lemon-squeezy" | "paddle"; - chat_role: "user" | "assistant"; - notification_channel: "in_app" | "email"; - notification_type: "info" | "warning" | "error"; - payment_status: "pending" | "succeeded" | "failed"; - social_type: "TIKTOK" | "YOUTUBE" | "INSTAGRAM" | "TWITTER" | "SPOTIFY" | "APPLE"; - subscription_item_type: "flat" | "per_seat" | "metered"; + | "tasks.delete" + billing_provider: "stripe" | "lemon-squeezy" | "paddle" + chat_role: "user" | "assistant" + notification_channel: "in_app" | "email" + notification_type: "info" | "warning" | "error" + payment_status: "pending" | "succeeded" | "failed" + social_type: + | "TIKTOK" + | "YOUTUBE" + | "INSTAGRAM" + | "TWITTER" + | "SPOTIFY" + | "APPLE" + subscription_item_type: "flat" | "per_seat" | "metered" subscription_status: | "active" | "trialing" @@ -3827,131 +3894,133 @@ export type Database = { | "unpaid" | "incomplete" | "incomplete_expired" - | "paused"; - }; + | "paused" + } CompositeTypes: { invitation: { - email: string | null; - role: string | null; - }; - }; - }; -}; + email: string | null + role: string | null + } + } + } +} -type DatabaseWithoutInternals = Omit; +type DatabaseWithoutInternals = Omit -type DefaultSchema = DatabaseWithoutInternals[Extract]; +type DefaultSchema = DatabaseWithoutInternals[Extract] export type Tables< DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R; + Row: infer R } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R; + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R } ? R : never - : never; + : never export type TablesInsert< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I; + Insert: infer I } ? I : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I; + Insert: infer I } ? I : never - : never; + : never export type TablesUpdate< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U; + Update: infer U } ? U : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U; + Update: infer U } ? U : never - : never; + : never export type Enums< DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, > = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never; + : never export type CompositeTypes< PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, > = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never; + : never export const Constants = { public: { @@ -3970,7 +4039,14 @@ export const Constants = { notification_channel: ["in_app", "email"], notification_type: ["info", "warning", "error"], payment_status: ["pending", "succeeded", "failed"], - social_type: ["TIKTOK", "YOUTUBE", "INSTAGRAM", "TWITTER", "SPOTIFY", "APPLE"], + social_type: [ + "TIKTOK", + "YOUTUBE", + "INSTAGRAM", + "TWITTER", + "SPOTIFY", + "APPLE", + ], subscription_item_type: ["flat", "per_seat", "metered"], subscription_status: [ "active", @@ -3984,4 +4060,4 @@ export const Constants = { ], }, }, -} as const; +} as const From 76fcfd4e03ca15e564042bed7886a5df4cde2690 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:45:46 -0500 Subject: [PATCH 24/42] fix: remove entity_type from JSDoc comments in route files --- app/api/connectors/authorize/route.ts | 3 +-- app/api/connectors/route.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index c35d228b..9150a365 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -23,8 +23,7 @@ export async function OPTIONS() { * Request body: * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) * - callback_url: Optional custom callback URL after OAuth - * - entity_type: "user" (default) or "artist" - * - entity_id: Required when entity_type is "artist" + * - entity_id: Optional entity ID (e.g., artist ID) for entity-specific connections * * @param request * @returns The redirect URL for OAuth authorization diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index 77a30e48..249090a0 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -20,8 +20,7 @@ export async function OPTIONS() { * List all available connectors and their connection status. * * Query params: - * - entity_type (optional): "user" (default) or "artist" - * - entity_id (required when entity_type=artist): The artist ID + * - entity_id (optional): Entity ID for entity-specific connections (e.g., artist ID) * * Authentication: x-api-key OR Authorization Bearer token required. * @@ -39,8 +38,7 @@ export async function GET(request: NextRequest) { * * Body: * - connected_account_id (required): The connected account ID to disconnect - * - entity_type (optional): "user" (default) or "artist" - * - entity_id (required when entity_type=artist): The artist ID + * - entity_id (optional): Entity ID for ownership verification (e.g., artist ID) * * Authentication: x-api-key OR Authorization Bearer token required. * From 42d7b8552fbb7b28661799f7c88d5f4fe5680e5c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:50:58 -0500 Subject: [PATCH 25/42] fix: replace 'user' with 'account' in all composio JSDoc comments --- lib/composio/connectors/authorizeConnector.ts | 10 +++++----- lib/composio/connectors/authorizeConnectorHandler.ts | 2 +- lib/composio/connectors/disconnectConnectorHandler.ts | 2 +- .../connectors/validateAuthorizeConnectorBody.ts | 2 +- .../connectors/validateAuthorizeConnectorRequest.ts | 2 +- .../connectors/validateDisconnectConnectorBody.ts | 2 +- .../connectors/validateDisconnectConnectorRequest.ts | 2 +- lib/composio/connectors/validateGetConnectorsQuery.ts | 2 +- .../connectors/validateGetConnectorsRequest.ts | 2 +- lib/composio/connectors/verifyConnectorOwnership.ts | 10 +++++----- lib/composio/getCallbackUrl.ts | 4 ++-- lib/composio/toolRouter/createToolRouterSession.ts | 8 ++++---- lib/composio/toolRouter/getTools.ts | 8 ++++---- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index d8c17038..1d3946ca 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -14,7 +14,7 @@ export interface AuthorizeResult { */ export interface AuthorizeConnectorOptions { /** - * Custom auth configs for toolkits that require user-provided OAuth credentials. + * Custom auth configs for toolkits that require custom OAuth credentials. * e.g., { tiktok: "ac_xxxxx" } */ authConfigs?: Record; @@ -23,7 +23,7 @@ export interface AuthorizeConnectorOptions { */ customCallbackUrl?: string; /** - * If true, this is an entity connection (not the user's own). + * If true, this is an entity connection (not the account's own). * Used to determine callback URL destination. */ isEntityConnection?: boolean; @@ -32,8 +32,8 @@ export interface AuthorizeConnectorOptions { /** * Generate an OAuth authorization URL for a connector. * - * The entityId is an account ID - either the user's own account or - * another account (like an artist) they have access to. + * The entityId is an account ID - either the caller's own account or + * another entity (like an artist) they have access to. * * @param entityId - The account ID to store the connection under * @param connector - The connector slug (e.g., "googlesheets", "tiktok") @@ -60,7 +60,7 @@ export async function authorizeConnector( toolkit: connector, }); } else { - // User's own connection: redirect to settings + // Account's own connection: redirect to settings callbackUrl = getCallbackUrl({ destination: "connectors" }); } diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index 284086bb..701242a2 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -8,7 +8,7 @@ import { authorizeConnector } from "./authorizeConnector"; * Handler for POST /api/connectors/authorize. * * Generates an OAuth authorization URL for a specific connector. - * Supports connecting for the authenticated user or another entity (via entity_id). + * Supports connecting for the authenticated account or another entity (via entity_id). * * @param request - The incoming request * @returns The redirect URL for OAuth authorization diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts index 5f88f5ae..85a0906c 100644 --- a/lib/composio/connectors/disconnectConnectorHandler.ts +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -8,7 +8,7 @@ import { disconnectConnector } from "./disconnectConnector"; * Handler for DELETE /api/connectors. * * Disconnects a connected account from Composio. - * Supports disconnecting for the authenticated user or another entity (via entity_id). + * Supports disconnecting for the authenticated account or another entity (via entity_id). * * @param request - The incoming request * @returns Success status diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index 3a5d2961..c338f1fd 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -38,7 +38,7 @@ export type AuthorizeConnectorBody = z.infer; /** * Validates query params for GET /api/connectors. * - * - No params: Returns connectors for the authenticated user + * - No params: Returns connectors for the authenticated account * - entity_id=uuid: Returns connectors for that entity (after access check) * * @param searchParams - The URL search params diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index 3ee6f9c2..901e4e36 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -58,7 +58,7 @@ export async function validateGetConnectorsRequest( }; } - // No entity_id: use the authenticated user's account + // No entity_id: use the authenticated account return { composioEntityId: accountId, }; diff --git a/lib/composio/connectors/verifyConnectorOwnership.ts b/lib/composio/connectors/verifyConnectorOwnership.ts index 9e963ac2..ff91e36d 100644 --- a/lib/composio/connectors/verifyConnectorOwnership.ts +++ b/lib/composio/connectors/verifyConnectorOwnership.ts @@ -1,14 +1,14 @@ import { getConnectors } from "./getConnectors"; /** - * Verifies that a connected account ID belongs to the specified user. + * Verifies that a connected account ID belongs to the specified account. * * Why: Before disconnecting a connector, we must verify ownership to prevent - * users from disconnecting other users' connectors (authorization bypass). + * accounts from disconnecting other accounts' connectors (authorization bypass). * - * @param accountId - The authenticated user's account ID + * @param accountId - The authenticated account ID * @param connectedAccountId - The connected account ID to verify - * @returns true if the connected account belongs to the user, false otherwise + * @returns true if the connected account belongs to this account, false otherwise */ export async function verifyConnectorOwnership( accountId: string, @@ -16,6 +16,6 @@ export async function verifyConnectorOwnership( ): Promise { const connectors = await getConnectors(accountId); - // Check if any of the user's connectors have this connected account ID + // Check if any of the account's connectors have this connected account ID return connectors.some(connector => connector.connectedAccountId === connectedAccountId); } diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index bff700f0..0bc663aa 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -3,9 +3,9 @@ import { getFrontendBaseUrl } from "./getFrontendBaseUrl"; /** * Build OAuth callback URL based on environment and destination. * - * Why: Composio redirects users back after OAuth. We need different + * Why: Composio redirects back after OAuth. We need different * destinations depending on context (chat for entity connections, - * settings page for user connections). + * settings page for account connections). */ type CallbackDestination = "chat" | "connectors" | "entity-connectors"; diff --git a/lib/composio/toolRouter/createToolRouterSession.ts b/lib/composio/toolRouter/createToolRouterSession.ts index 60c6d33d..f1a70dd4 100644 --- a/lib/composio/toolRouter/createToolRouterSession.ts +++ b/lib/composio/toolRouter/createToolRouterSession.ts @@ -8,14 +8,14 @@ import { getCallbackUrl } from "../getCallbackUrl"; const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"]; /** - * Create a Composio Tool Router session for a user. + * Create a Composio Tool Router session for an account. * - * @param userId - Unique identifier for the user (accountId) + * @param accountId - Unique identifier for the account * @param roomId - Optional chat room ID for OAuth redirect * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections */ export async function createToolRouterSession( - userId: string, + accountId: string, roomId?: string, artistConnections?: Record, ) { @@ -26,7 +26,7 @@ export async function createToolRouterSession( roomId, }); - const session = await composio.create(userId, { + const session = await composio.create(accountId, { toolkits: ENABLED_TOOLKITS, manageConnections: { callbackUrl, diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index db727b18..e5575250 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -37,7 +37,7 @@ function isValidTool(tool: unknown): tool is Tool { } /** - * Get Composio Tool Router tools for a user. + * Get Composio Tool Router tools for an account. * * Returns a filtered subset of meta-tools: * - COMPOSIO_MANAGE_CONNECTIONS - OAuth/auth management @@ -52,13 +52,13 @@ function isValidTool(tool: unknown): tool is Tool { * - COMPOSIO_API_KEY is not set * - @composio packages fail to load (bundler incompatibility) * - * @param userId - Unique identifier for the user (accountId) + * @param accountId - Unique identifier for the account * @param artistId - Optional artist ID to use artist-specific Composio connections * @param roomId - Optional chat room ID for OAuth redirect * @returns ToolSet containing filtered Vercel AI SDK tools */ export async function getComposioTools( - userId: string, + accountId: string, artistId?: string, roomId?: string, ): Promise { @@ -78,7 +78,7 @@ export async function getComposioTools( } } - const session = await createToolRouterSession(userId, roomId, artistConnections); + const session = await createToolRouterSession(accountId, roomId, artistConnections); const allTools = await session.tools(); // Filter to only allowed tools with runtime validation From 26a30e6b0b93eef3098aff34cddf08f2a02a6f56 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:54:59 -0500 Subject: [PATCH 26/42] refactor: use validateAuthContext instead of validateAccountIdHeaders Updated all connector validation files to use validateAuthContext() for consistent auth handling across the codebase: - validateAuthorizeConnectorRequest.ts - validateDisconnectConnectorRequest.ts - validateGetConnectorsRequest.ts - Updated corresponding test files validateAuthContext supports both x-api-key and Bearer token auth, plus account_id override and organization access validation. --- .../validateAuthorizeConnectorRequest.test.ts | 24 ++++++++++++------- ...validateDisconnectConnectorRequest.test.ts | 24 ++++++++++++------- .../validateGetConnectorsRequest.test.ts | 24 ++++++++++++------- .../validateAuthorizeConnectorRequest.ts | 6 ++--- .../validateDisconnectConnectorRequest.ts | 6 ++--- .../validateGetConnectorsRequest.ts | 6 ++--- 6 files changed, 57 insertions(+), 33 deletions(-) diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index c0682545..0b8bab01 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ - validateAccountIdHeaders: vi.fn(), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), })); vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ @@ -23,7 +23,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); it("should return error if auth fails", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue( + vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ error: "Unauthorized" }, { status: 401 }), ); @@ -40,8 +40,10 @@ describe("validateAuthorizeConnectorRequest", () => { it("should return accountId as composioEntityId with isEntityConnection=false when no entity_id", async () => { const mockAccountId = "account-123"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); const request = new NextRequest("http://localhost/api/connectors/authorize", { @@ -62,8 +64,10 @@ describe("validateAuthorizeConnectorRequest", () => { it("should return entity_id as composioEntityId with isEntityConnection=true when entity_id provided", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); @@ -87,8 +91,10 @@ describe("validateAuthorizeConnectorRequest", () => { it("should return 403 when entity_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); @@ -109,8 +115,10 @@ describe("validateAuthorizeConnectorRequest", () => { const originalEnv = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = "ac_test123"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts index d1cdaa2b..eecbed2c 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; -vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ - validateAccountIdHeaders: vi.fn(), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), })); vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ @@ -28,7 +28,7 @@ describe("validateDisconnectConnectorRequest", () => { }); it("should return error if auth fails", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue( + vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ error: "Unauthorized" }, { status: 401 }), ); @@ -44,8 +44,10 @@ describe("validateDisconnectConnectorRequest", () => { }); it("should verify ownership when no entity_id provided", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", + orgId: null, + authToken: "test-token", }); vi.mocked(verifyConnectorOwnership).mockResolvedValue(true); @@ -64,8 +66,10 @@ describe("validateDisconnectConnectorRequest", () => { }); it("should return 403 when ownership verification fails", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", + orgId: null, + authToken: "test-token", }); vi.mocked(verifyConnectorOwnership).mockResolvedValue(false); @@ -82,8 +86,10 @@ describe("validateDisconnectConnectorRequest", () => { it("should check entity access when entity_id provided", async () => { const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); @@ -104,8 +110,10 @@ describe("validateDisconnectConnectorRequest", () => { it("should return 403 when entity access denied", async () => { const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index 06a26748..fe3b75a8 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; -vi.mock("@/lib/accounts/validateAccountIdHeaders", () => ({ - validateAccountIdHeaders: vi.fn(), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), })); vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ @@ -23,7 +23,7 @@ describe("validateGetConnectorsRequest", () => { }); it("should return error if auth fails", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue( + vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ error: "Unauthorized" }, { status: 401 }), ); @@ -37,8 +37,10 @@ describe("validateGetConnectorsRequest", () => { it("should return accountId as composioEntityId when no entity_id provided", async () => { const mockAccountId = "account-123"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); const request = new NextRequest("http://localhost/api/connectors"); @@ -53,8 +55,10 @@ describe("validateGetConnectorsRequest", () => { it("should return entity_id as composioEntityId with allowedToolkits when entity_id provided", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); @@ -72,8 +76,10 @@ describe("validateGetConnectorsRequest", () => { it("should return 403 when entity_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, + orgId: null, + authToken: "test-token", }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); @@ -86,8 +92,10 @@ describe("validateGetConnectorsRequest", () => { }); it("should return 400 for invalid entity_id format", async () => { - vi.mocked(validateAccountIdHeaders).mockResolvedValue({ + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", + orgId: null, + authToken: "test-token", }); const request = new NextRequest("http://localhost/api/connectors?entity_id=not-a-uuid"); diff --git a/lib/composio/connectors/validateAuthorizeConnectorRequest.ts b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts index 0fea919d..2966b3ca 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorRequest.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAuthorizeConnectorBody } from "./validateAuthorizeConnectorBody"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; @@ -32,8 +32,8 @@ export async function validateAuthorizeConnectorRequest( ): Promise { const headers = getCorsHeaders(); - // 1. Validate authentication - const authResult = await validateAccountIdHeaders(request); + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) { return authResult; } diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts index d6d8081e..68967aa3 100644 --- a/lib/composio/connectors/validateDisconnectConnectorRequest.ts +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateDisconnectConnectorBody } from "./validateDisconnectConnectorBody"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; @@ -30,8 +30,8 @@ export async function validateDisconnectConnectorRequest( ): Promise { const headers = getCorsHeaders(); - // 1. Validate authentication - const authResult = await validateAccountIdHeaders(request); + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) { return authResult; } diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index 901e4e36..b90f7c0d 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateGetConnectorsQuery } from "./validateGetConnectorsQuery"; import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; @@ -30,8 +30,8 @@ export async function validateGetConnectorsRequest( ): Promise { const headers = getCorsHeaders(); - // 1. Validate authentication - const authResult = await validateAccountIdHeaders(request); + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) { return authResult; } From 9cef62038fcb71b296e6651ca27f7407287540ab Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:03:30 -0500 Subject: [PATCH 27/42] fix: restore artistId wiring and add access check in getComposioTools - Restored artistId parameter in setupToolsForRequest (accidentally reverted) - Added checkAccountArtistAccess() before fetching artist connections - If access denied, silently skips artist connections (no throw) - Updated tests for both setupToolsForRequest and getComposioTools --- .../__tests__/setupToolsForRequest.test.ts | 19 +++++++++++-- lib/chat/setupToolsForRequest.ts | 6 ++--- .../__tests__/getComposioTools.test.ts | 27 ++++++++++++++++++- lib/composio/toolRouter/getTools.ts | 14 +++++++--- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index 15522f62..eab54888 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -81,18 +81,33 @@ describe("setupToolsForRequest", () => { }); describe("Composio tools integration", () => { - it("calls getComposioTools with accountId and roomId", async () => { + it("calls getComposioTools with accountId, artistId, and roomId", async () => { const body: ChatRequestBody = { accountId: "account-123", orgId: null, authToken: "test-token-123", + artistId: "artist-789", roomId: "room-456", messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], }; await setupToolsForRequest(body); - expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "room-456"); + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "artist-789", "room-456"); + }); + + it("passes undefined artistId when not provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + orgId: null, + authToken: "test-token-123", + roomId: "room-456", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], + }; + + await setupToolsForRequest(body); + + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", undefined, "room-456"); }); it("includes Composio tools in result", async () => { diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 77b1747d..ba586e19 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -8,18 +8,18 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; * Sets up and filters tools for a chat request. * Aggregates tools from: * - MCP server (via HTTP transport to /api/mcp for proper auth) - * - Composio Tool Router (Google Sheets, Google Drive, Google Docs) + * - Composio Tool Router (Google Sheets, Google Drive, Google Docs, TikTok) * * @param body - The chat request body * @returns Filtered tool set ready for use */ export async function setupToolsForRequest(body: ChatRequestBody): Promise { - const { accountId, roomId, excludeTools, authToken } = body; + const { accountId, artistId, roomId, excludeTools, authToken } = body; // Fetch MCP tools and Composio tools in parallel - they're independent const [mcpTools, composioTools] = await Promise.all([ authToken ? getMcpTools(authToken) : Promise.resolve({}), - getComposioTools(accountId, roomId), + getComposioTools(accountId, artistId, roomId), ]); // Merge all tools diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts index 63c1e283..34789aac 100644 --- a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -3,6 +3,7 @@ import { getComposioTools } from "../getTools"; import { createToolRouterSession } from "../createToolRouterSession"; import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; // Mock dependencies vi.mock("../createToolRouterSession", () => ({ @@ -13,6 +14,10 @@ vi.mock("../getArtistConnectionsFromComposio", () => ({ getArtistConnectionsFromComposio: vi.fn(), })); +vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + // Mock valid tool structure const createMockTool = () => ({ description: "Test tool", @@ -55,8 +60,9 @@ describe("getComposioTools", () => { expect(createToolRouterSession).toHaveBeenCalledWith("account-123", undefined, undefined); }); - it("should fetch and pass artist connections when artistId is provided", async () => { + it("should fetch and pass artist connections when artistId is provided and access is granted", async () => { const mockConnections = { tiktok: "tiktok-account-456" }; + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue(mockConnections); const mockSession = { @@ -68,11 +74,30 @@ describe("getComposioTools", () => { await getComposioTools("account-123", "artist-456", "room-789"); + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-456"); expect(createToolRouterSession).toHaveBeenCalledWith("account-123", "room-789", mockConnections); }); + it("should skip artist connections when access is denied", async () => { + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("account-123", "artist-456", "room-789"); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(getArtistConnectionsFromComposio).not.toHaveBeenCalled(); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", "room-789", undefined); + }); + it("should pass undefined when artist has no connections", async () => { + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue({}); const mockSession = { diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index e5575250..1e63953f 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,5 +1,6 @@ import { createToolRouterSession } from "./createToolRouterSession"; import { getArtistConnectionsFromComposio } from "./getArtistConnectionsFromComposio"; +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; import type { Tool, ToolSet } from "ai"; /** @@ -69,13 +70,18 @@ export async function getComposioTools( try { // Fetch artist-specific connections from Composio if artistId is provided + // Only fetch if the account has access to this artist let artistConnections: Record | undefined; if (artistId) { - artistConnections = await getArtistConnectionsFromComposio(artistId); - // Only pass if there are actual connections - if (Object.keys(artistConnections).length === 0) { - artistConnections = undefined; + const hasAccess = await checkAccountArtistAccess(accountId, artistId); + if (hasAccess) { + artistConnections = await getArtistConnectionsFromComposio(artistId); + // Only pass if there are actual connections + if (Object.keys(artistConnections).length === 0) { + artistConnections = undefined; + } } + // If no access, silently skip artist connections (don't throw) } const session = await createToolRouterSession(accountId, roomId, artistConnections); From ddc5eabca08144dfc555aab065d57486af3dbae8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:46:41 -0500 Subject: [PATCH 28/42] refactor: remove entity-connectors callback URL logic (deferred to separate PR) --- lib/composio/__tests__/getCallbackUrl.test.ts | 42 ------------------- .../__tests__/authorizeConnector.test.ts | 14 +------ .../authorizeConnectorHandler.test.ts | 9 +--- .../validateAuthorizeConnectorRequest.test.ts | 6 +-- lib/composio/connectors/authorizeConnector.ts | 22 +--------- .../connectors/authorizeConnectorHandler.ts | 3 +- .../validateAuthorizeConnectorRequest.ts | 3 -- lib/composio/getCallbackUrl.ts | 22 ++++------ 8 files changed, 17 insertions(+), 104 deletions(-) delete mode 100644 lib/composio/__tests__/getCallbackUrl.test.ts diff --git a/lib/composio/__tests__/getCallbackUrl.test.ts b/lib/composio/__tests__/getCallbackUrl.test.ts deleted file mode 100644 index 268f43f7..00000000 --- a/lib/composio/__tests__/getCallbackUrl.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getCallbackUrl } from "../getCallbackUrl"; - -vi.mock("../getFrontendBaseUrl", () => ({ - getFrontendBaseUrl: vi.fn(() => "https://chat.recoupable.com"), -})); - -describe("getCallbackUrl", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("connectors destination", () => { - it("should return settings/connectors URL", () => { - const url = getCallbackUrl({ destination: "connectors" }); - expect(url).toBe("https://chat.recoupable.com/settings/connectors?connected=true"); - }); - }); - - describe("entity-connectors destination", () => { - it("should return chat URL with entity and toolkit params", () => { - const url = getCallbackUrl({ - destination: "entity-connectors", - entityId: "entity-123", - toolkit: "tiktok", - }); - expect(url).toBe("https://chat.recoupable.com/chat?artist_connected=entity-123&toolkit=tiktok"); - }); - }); - - describe("chat destination", () => { - it("should return chat URL without roomId", () => { - const url = getCallbackUrl({ destination: "chat" }); - expect(url).toBe("https://chat.recoupable.com/chat?connected=true"); - }); - - it("should return chat URL with roomId", () => { - const url = getCallbackUrl({ destination: "chat", roomId: "room-123" }); - expect(url).toBe("https://chat.recoupable.com/chat/room-123?connected=true"); - }); - }); -}); diff --git a/lib/composio/connectors/__tests__/authorizeConnector.test.ts b/lib/composio/connectors/__tests__/authorizeConnector.test.ts index 8bb16666..70b2a30c 100644 --- a/lib/composio/connectors/__tests__/authorizeConnector.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnector.test.ts @@ -41,22 +41,12 @@ describe("authorizeConnector", () => { }); }); - it("should use connectors destination when not entity connection", async () => { - await authorizeConnector("account-123", "googlesheets", { isEntityConnection: false }); + it("should use connectors destination by default", async () => { + await authorizeConnector("account-123", "googlesheets"); expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "connectors" }); }); - it("should use entity-connectors destination when entity connection", async () => { - await authorizeConnector("entity-456", "tiktok", { isEntityConnection: true }); - - expect(getCallbackUrl).toHaveBeenCalledWith({ - destination: "entity-connectors", - entityId: "entity-456", - toolkit: "tiktok", - }); - }); - it("should use custom callback URL when provided", async () => { const customUrl = "https://custom.example.com/callback"; await authorizeConnector("account-123", "googlesheets", { customCallbackUrl: customUrl }); diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts index be29db65..7a5a3bf1 100644 --- a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -39,7 +39,6 @@ describe("authorizeConnectorHandler", () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ composioEntityId: "account-123", connector: "googlesheets", - isEntityConnection: false, }); vi.mocked(authorizeConnector).mockResolvedValue({ connector: "googlesheets", @@ -54,7 +53,6 @@ describe("authorizeConnectorHandler", () => { expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { customCallbackUrl: undefined, authConfigs: undefined, - isEntityConnection: false, }); expect(result.status).toBe(200); const body = await result.json(); @@ -62,12 +60,11 @@ describe("authorizeConnectorHandler", () => { expect(body.data.redirectUrl).toBe("https://oauth.example.com/auth"); }); - it("should call authorizeConnector with isEntityConnection true for entity connection", async () => { + it("should call authorizeConnector with authConfigs for entity connection", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ composioEntityId: "entity-456", connector: "tiktok", authConfigs: { tiktok: "ac_123" }, - isEntityConnection: true, }); vi.mocked(authorizeConnector).mockResolvedValue({ connector: "tiktok", @@ -82,7 +79,6 @@ describe("authorizeConnectorHandler", () => { expect(authorizeConnector).toHaveBeenCalledWith("entity-456", "tiktok", { customCallbackUrl: undefined, authConfigs: { tiktok: "ac_123" }, - isEntityConnection: true, }); }); @@ -91,7 +87,6 @@ describe("authorizeConnectorHandler", () => { composioEntityId: "account-123", connector: "googlesheets", callbackUrl: "https://custom.example.com/callback", - isEntityConnection: false, }); vi.mocked(authorizeConnector).mockResolvedValue({ connector: "googlesheets", @@ -106,7 +101,6 @@ describe("authorizeConnectorHandler", () => { expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { customCallbackUrl: "https://custom.example.com/callback", authConfigs: undefined, - isEntityConnection: false, }); }); @@ -114,7 +108,6 @@ describe("authorizeConnectorHandler", () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ composioEntityId: "account-123", connector: "googlesheets", - isEntityConnection: false, }); vi.mocked(authorizeConnector).mockRejectedValue(new Error("OAuth failed")); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index 0b8bab01..ebc40f0d 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -38,7 +38,7 @@ describe("validateAuthorizeConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId as composioEntityId with isEntityConnection=false when no entity_id", async () => { + it("should return accountId as composioEntityId when no entity_id", async () => { const mockAccountId = "account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, @@ -57,11 +57,10 @@ describe("validateAuthorizeConnectorRequest", () => { composioEntityId: mockAccountId, connector: "googlesheets", callbackUrl: undefined, - isEntityConnection: false, }); }); - it("should return entity_id as composioEntityId with isEntityConnection=true when entity_id provided", async () => { + it("should return entity_id as composioEntityId when entity_id provided", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -84,7 +83,6 @@ describe("validateAuthorizeConnectorRequest", () => { connector: "tiktok", callbackUrl: undefined, authConfigs: undefined, - isEntityConnection: true, }); }); diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index 1d3946ca..ec808ad1 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -22,11 +22,6 @@ export interface AuthorizeConnectorOptions { * Custom callback URL (overrides default). */ customCallbackUrl?: string; - /** - * If true, this is an entity connection (not the account's own). - * Used to determine callback URL destination. - */ - isEntityConnection?: boolean; } /** @@ -45,24 +40,11 @@ export async function authorizeConnector( connector: string, options: AuthorizeConnectorOptions = {}, ): Promise { - const { authConfigs, customCallbackUrl, isEntityConnection } = options; + const { authConfigs, customCallbackUrl } = options; const composio = await getComposioClient(); // Determine callback URL - let callbackUrl: string; - if (customCallbackUrl) { - callbackUrl = customCallbackUrl; - } else if (isEntityConnection) { - // Entity connection: redirect to chat with entity info - callbackUrl = getCallbackUrl({ - destination: "entity-connectors", - entityId, - toolkit: connector, - }); - } else { - // Account's own connection: redirect to settings - callbackUrl = getCallbackUrl({ destination: "connectors" }); - } + const callbackUrl = customCallbackUrl ?? getCallbackUrl({ destination: "connectors" }); // Create session with optional auth configs const session = await composio.create(entityId, { diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index 701242a2..24ef9d63 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -23,13 +23,12 @@ export async function authorizeConnectorHandler(request: NextRequest): Promise; - isEntityConnection?: boolean; } /** @@ -65,7 +64,6 @@ export async function validateAuthorizeConnectorRequest( connector, callbackUrl: callback_url, authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, - isEntityConnection: true, }; } @@ -74,6 +72,5 @@ export async function validateAuthorizeConnectorRequest( composioEntityId: accountId, connector, callbackUrl: callback_url, - isEntityConnection: false, }; } diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index 0bc663aa..570c9251 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -3,22 +3,22 @@ import { getFrontendBaseUrl } from "./getFrontendBaseUrl"; /** * Build OAuth callback URL based on environment and destination. * - * Why: Composio redirects back after OAuth. We need different - * destinations depending on context (chat for entity connections, - * settings page for account connections). + * Why: Composio redirects users back after OAuth. We need different + * destinations depending on context (chat room vs settings page). */ -type CallbackDestination = "chat" | "connectors" | "entity-connectors"; +type CallbackDestination = "chat" | "connectors"; -type CallbackOptions = - | { destination: "chat"; roomId?: string } - | { destination: "connectors" } - | { destination: "entity-connectors"; entityId: string; toolkit: string }; +interface CallbackOptions { + destination: CallbackDestination; + roomId?: string; +} /** * Build callback URL for OAuth redirects. * - * @param options - Callback configuration + * @param options.destination - Where to redirect: "chat" or "connectors" + * @param options.roomId - For chat destination, the room ID to return to * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { @@ -28,10 +28,6 @@ export function getCallbackUrl(options: CallbackOptions): string { return `${baseUrl}/settings/connectors?connected=true`; } - if (options.destination === "entity-connectors") { - return `${baseUrl}/chat?artist_connected=${options.entityId}&toolkit=${options.toolkit}`; - } - // Chat destination const path = options.roomId ? `/chat/${options.roomId}` : "/chat"; return `${baseUrl}${path}?connected=true`; From 29b1cdf17f0148cf09717f88e5023be4d3174965 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:30:21 -0500 Subject: [PATCH 29/42] refactor: rename entity_id to account_id in connectors API --- app/api/connectors/authorize/route.ts | 2 +- app/api/connectors/route.ts | 4 ++-- .../__tests__/getConnectorsHandler.test.ts | 4 ++-- .../validateAuthorizeConnectorBody.test.ts | 16 ++++++++-------- .../validateAuthorizeConnectorRequest.test.ts | 12 ++++++------ .../validateDisconnectConnectorBody.test.ts | 12 ++++++------ .../validateDisconnectConnectorRequest.test.ts | 8 ++++---- .../validateGetConnectorsQuery.test.ts | 10 +++++----- .../validateGetConnectorsRequest.test.ts | 14 +++++++------- .../connectors/authorizeConnectorHandler.ts | 2 +- .../connectors/disconnectConnectorHandler.ts | 2 +- .../connectors/getConnectorsHandler.ts | 2 +- .../validateAuthorizeConnectorBody.ts | 12 ++++++------ .../validateAuthorizeConnectorRequest.ts | 18 +++++++++--------- .../validateDisconnectConnectorBody.ts | 6 +++--- .../validateDisconnectConnectorRequest.ts | 10 +++++----- .../connectors/validateGetConnectorsQuery.ts | 6 +++--- .../connectors/validateGetConnectorsRequest.ts | 16 ++++++++-------- 18 files changed, 78 insertions(+), 78 deletions(-) diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index 9150a365..f29a71b6 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -23,7 +23,7 @@ export async function OPTIONS() { * Request body: * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) * - callback_url: Optional custom callback URL after OAuth - * - entity_id: Optional entity ID (e.g., artist ID) for entity-specific connections + * - account_id: Optional entity ID (e.g., artist ID) for entity-specific connections * * @param request * @returns The redirect URL for OAuth authorization diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index 249090a0..cc842b57 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -20,7 +20,7 @@ export async function OPTIONS() { * List all available connectors and their connection status. * * Query params: - * - entity_id (optional): Entity ID for entity-specific connections (e.g., artist ID) + * - account_id (optional): Entity ID for entity-specific connections (e.g., artist ID) * * Authentication: x-api-key OR Authorization Bearer token required. * @@ -38,7 +38,7 @@ export async function GET(request: NextRequest) { * * Body: * - connected_account_id (required): The connected account ID to disconnect - * - entity_id (optional): Entity ID for ownership verification (e.g., artist ID) + * - account_id (optional): Entity ID for ownership verification (e.g., artist ID) * * Authentication: x-api-key OR Authorization Bearer token required. * diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index bf3b4837..29acbecf 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -53,7 +53,7 @@ describe("getConnectorsHandler", () => { expect(body.data.connectors[0].slug).toBe("googlesheets"); }); - it("should pass allowedToolkits when entity_id is provided", async () => { + it("should pass allowedToolkits when account_id is provided", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ composioEntityId: "entity-456", allowedToolkits: ["tiktok"], @@ -64,7 +64,7 @@ describe("getConnectorsHandler", () => { ]); const request = new NextRequest( - "http://localhost/api/connectors?entity_id=entity-456", + "http://localhost/api/connectors?account_id=entity-456", ); await getConnectorsHandler(request); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts index fa89e87b..95f038e4 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("validateAuthorizeConnectorBody", () => { - it("should accept valid connector request without entity_id", () => { + it("should accept valid connector request without account_id", () => { const result = validateAuthorizeConnectorBody({ connector: "googlesheets", }); @@ -18,16 +18,16 @@ describe("validateAuthorizeConnectorBody", () => { }); }); - it("should accept valid connector request with entity_id for allowed connector", () => { + it("should accept valid connector request with account_id for allowed connector", () => { const result = validateAuthorizeConnectorBody({ connector: "tiktok", - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connector: "tiktok", - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); }); @@ -60,10 +60,10 @@ describe("validateAuthorizeConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 when entity_id is provided but connector is not allowed", () => { + it("should return 400 when account_id is provided but connector is not allowed", () => { const result = validateAuthorizeConnectorBody({ connector: "googlesheets", - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).toBeInstanceOf(NextResponse); @@ -82,10 +82,10 @@ describe("validateAuthorizeConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 for invalid entity_id UUID format", () => { + it("should return 400 for invalid account_id UUID format", () => { const result = validateAuthorizeConnectorBody({ connector: "tiktok", - entity_id: "not-a-uuid", + account_id: "not-a-uuid", }); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index ebc40f0d..5a31bbeb 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -38,7 +38,7 @@ describe("validateAuthorizeConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId as composioEntityId when no entity_id", async () => { + it("should return accountId as composioEntityId when no account_id", async () => { const mockAccountId = "account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, @@ -60,7 +60,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); }); - it("should return entity_id as composioEntityId when entity_id provided", async () => { + it("should return account_id as composioEntityId when account_id provided", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -72,7 +72,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); @@ -86,7 +86,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); }); - it("should return 403 when entity_id provided but no access", async () => { + it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -98,7 +98,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); @@ -122,7 +122,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", entity_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), }); const result = await validateAuthorizeConnectorRequest(request); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts index f8e10918..002c6d1e 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); describe("validateDisconnectConnectorBody", () => { - it("should accept valid disconnect request without entity_id", () => { + it("should accept valid disconnect request without account_id", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", }); @@ -18,16 +18,16 @@ describe("validateDisconnectConnectorBody", () => { }); }); - it("should accept valid disconnect request with entity_id", () => { + it("should accept valid disconnect request with account_id", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connected_account_id: "ca_12345", - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); }); @@ -49,10 +49,10 @@ describe("validateDisconnectConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 for invalid entity_id UUID format", () => { + it("should return 400 for invalid account_id UUID format", () => { const result = validateDisconnectConnectorBody({ connected_account_id: "ca_12345", - entity_id: "not-a-uuid", + account_id: "not-a-uuid", }); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts index eecbed2c..fb5dd7ac 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -43,7 +43,7 @@ describe("validateDisconnectConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should verify ownership when no entity_id provided", async () => { + it("should verify ownership when no account_id provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -84,7 +84,7 @@ describe("validateDisconnectConnectorRequest", () => { expect(response.status).toBe(403); }); - it("should check entity access when entity_id provided", async () => { + it("should check entity access when account_id provided", async () => { const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", @@ -95,7 +95,7 @@ describe("validateDisconnectConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_123", entity_id: mockEntityId }), + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), }); const result = await validateDisconnectConnectorRequest(request); @@ -119,7 +119,7 @@ describe("validateDisconnectConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_123", entity_id: mockEntityId }), + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), }); const result = await validateDisconnectConnectorRequest(request); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts index 616c919f..d6b15dc3 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts @@ -15,21 +15,21 @@ describe("validateGetConnectorsQuery", () => { expect(result).toEqual({}); }); - it("should accept valid entity_id UUID", () => { + it("should accept valid account_id UUID", () => { const searchParams = new URLSearchParams({ - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); const result = validateGetConnectorsQuery(searchParams); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - entity_id: "550e8400-e29b-41d4-a716-446655440000", + account_id: "550e8400-e29b-41d4-a716-446655440000", }); }); - it("should return 400 for invalid entity_id UUID format", () => { + it("should return 400 for invalid account_id UUID format", () => { const searchParams = new URLSearchParams({ - entity_id: "not-a-uuid", + account_id: "not-a-uuid", }); const result = validateGetConnectorsQuery(searchParams); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index fe3b75a8..33ddb5dd 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -35,7 +35,7 @@ describe("validateGetConnectorsRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId as composioEntityId when no entity_id provided", async () => { + it("should return accountId as composioEntityId when no account_id provided", async () => { const mockAccountId = "account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, @@ -52,7 +52,7 @@ describe("validateGetConnectorsRequest", () => { }); }); - it("should return entity_id as composioEntityId with allowedToolkits when entity_id provided", async () => { + it("should return account_id as composioEntityId with allowedToolkits when account_id provided", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -62,7 +62,7 @@ describe("validateGetConnectorsRequest", () => { }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); - const request = new NextRequest(`http://localhost/api/connectors?entity_id=${mockEntityId}`); + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); @@ -73,7 +73,7 @@ describe("validateGetConnectorsRequest", () => { }); }); - it("should return 403 when entity_id provided but no access", async () => { + it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -83,7 +83,7 @@ describe("validateGetConnectorsRequest", () => { }); vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); - const request = new NextRequest(`http://localhost/api/connectors?entity_id=${mockEntityId}`); + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); expect(result).toBeInstanceOf(NextResponse); @@ -91,14 +91,14 @@ describe("validateGetConnectorsRequest", () => { expect(response.status).toBe(403); }); - it("should return 400 for invalid entity_id format", async () => { + it("should return 400 for invalid account_id format", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, authToken: "test-token", }); - const request = new NextRequest("http://localhost/api/connectors?entity_id=not-a-uuid"); + const request = new NextRequest("http://localhost/api/connectors?account_id=not-a-uuid"); const result = await validateGetConnectorsRequest(request); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index 24ef9d63..e1a45554 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -8,7 +8,7 @@ import { authorizeConnector } from "./authorizeConnector"; * Handler for POST /api/connectors/authorize. * * Generates an OAuth authorization URL for a specific connector. - * Supports connecting for the authenticated account or another entity (via entity_id). + * Supports connecting for the authenticated account or another entity (via account_id). * * @param request - The incoming request * @returns The redirect URL for OAuth authorization diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts index 85a0906c..299b19a1 100644 --- a/lib/composio/connectors/disconnectConnectorHandler.ts +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -8,7 +8,7 @@ import { disconnectConnector } from "./disconnectConnector"; * Handler for DELETE /api/connectors. * * Disconnects a connected account from Composio. - * Supports disconnecting for the authenticated account or another entity (via entity_id). + * Supports disconnecting for the authenticated account or another entity (via account_id). * * @param request - The incoming request * @returns Success status diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index 05649a97..ec65fa17 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -18,7 +18,7 @@ const CONNECTOR_DISPLAY_NAMES: Record = { * Handler for GET /api/connectors. * * Lists all available connectors and their connection status. - * Use entity_id query param to get connectors for a specific entity. + * Use account_id query param to get connectors for a specific entity. * * @param request - The incoming request * @returns List of connectors with connection status diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index c338f1fd..54433d89 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -9,12 +9,12 @@ export const authorizeConnectorBodySchema = z .string({ message: "connector is required" }) .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), callback_url: z.string().url("callback_url must be a valid URL").optional(), - entity_id: z.string().uuid("entity_id must be a valid UUID").optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }) .refine( data => { - // connector must be in ALLOWED_ARTIST_CONNECTORS when entity_id is provided - if (data.entity_id) { + // connector must be in ALLOWED_ARTIST_CONNECTORS when account_id is provided + if (data.account_id) { return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(data.connector); } return true; @@ -31,13 +31,13 @@ export type AuthorizeConnectorBody = z.infer 0 ? authConfigs : undefined, }; } - // No entity_id: use the authenticated account + // No account_id: use the authenticated account return { composioEntityId: accountId, connector, diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index 969decbb..c1b4b013 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -4,7 +4,7 @@ import { z } from "zod"; export const disconnectConnectorBodySchema = z.object({ connected_account_id: z.string().min(1, "connected_account_id is required"), - entity_id: z.string().uuid("entity_id must be a valid UUID").optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); export type DisconnectConnectorBody = z.infer; @@ -13,9 +13,9 @@ export type DisconnectConnectorBody = z.infer; @@ -12,7 +12,7 @@ export type GetConnectorsQuery = z.infer; * Validates query params for GET /api/connectors. * * - No params: Returns connectors for the authenticated account - * - entity_id=uuid: Returns connectors for that entity (after access check) + * - account_id=uuid: Returns connectors for that entity (after access check) * * @param searchParams - The URL search params * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. @@ -21,7 +21,7 @@ export function validateGetConnectorsQuery( searchParams: URLSearchParams, ): NextResponse | GetConnectorsQuery { const queryParams = { - entity_id: searchParams.get("entity_id") ?? undefined, + account_id: searchParams.get("account_id") ?? undefined, }; const result = getConnectorsQuerySchema.safeParse(queryParams); diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index b90f7c0d..c141500b 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -19,8 +19,8 @@ export interface GetConnectorsParams { * * Handles: * 1. Authentication (x-api-key or Bearer token) - * 2. Query param validation (entity_id) - * 3. Access verification (when entity_id is provided) + * 2. Query param validation (account_id) + * 3. Access verification (when account_id is provided) * * @param request - The incoming request * @returns NextResponse error or validated params @@ -43,22 +43,22 @@ export async function validateGetConnectorsRequest( if (validated instanceof NextResponse) { return validated; } - const { entity_id } = validated; + const { account_id } = validated; - // 3. If entity_id is provided, verify access and use that entity - if (entity_id) { - const hasAccess = await checkAccountArtistAccess(accountId, entity_id); + // 3. If account_id is provided, verify access and use that entity + if (account_id) { + const hasAccess = await checkAccountArtistAccess(accountId, account_id); if (!hasAccess) { return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); } return { - composioEntityId: entity_id, + composioEntityId: account_id, allowedToolkits: ALLOWED_ARTIST_CONNECTORS, }; } - // No entity_id: use the authenticated account + // No account_id: use the authenticated account return { composioEntityId: accountId, }; From 0752c373f46a4177321d803e598dfd3bd241420c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:49:27 -0500 Subject: [PATCH 30/42] feat: broaden account_id access to support self, artist, workspace, and org entities - Create checkAccountAccess in lib/auth for unified access check - Create checkAccountWorkspaceAccess in supabase lib - Move artist connector restriction from body validator to request validator - Add message field to disconnect response - Update all tests for broadened access patterns --- lib/auth/checkAccountAccess.ts | 71 +++++++++++++++ .../validateAuthorizeConnectorBody.test.ts | 12 ++- .../validateAuthorizeConnectorRequest.test.ts | 87 +++++++++++++++++-- ...validateDisconnectConnectorRequest.test.ts | 35 ++++++-- .../validateGetConnectorsRequest.test.ts | 73 ++++++++++++++-- .../connectors/disconnectConnectorHandler.ts | 2 +- .../validateAuthorizeConnectorBody.ts | 41 +++------ .../validateAuthorizeConnectorRequest.ts | 20 +++-- .../validateDisconnectConnectorRequest.ts | 10 +-- .../validateGetConnectorsRequest.ts | 11 +-- .../checkAccountWorkspaceAccess.ts | 31 +++++++ 11 files changed, 319 insertions(+), 74 deletions(-) create mode 100644 lib/auth/checkAccountAccess.ts create mode 100644 lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts diff --git a/lib/auth/checkAccountAccess.ts b/lib/auth/checkAccountAccess.ts new file mode 100644 index 00000000..841b7d19 --- /dev/null +++ b/lib/auth/checkAccountAccess.ts @@ -0,0 +1,71 @@ +import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountWorkspaceAccess } from "@/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +/** + * The type of entity the target account_id represents relative to the caller. + * + * - "self": The caller's own account + * - "artist": An artist the caller manages + * - "workspace": A workspace the caller owns + * - "organization": An organization the caller belongs to + */ +export type AccountEntityType = "self" | "artist" | "workspace" | "organization"; + +/** + * Result of checking account access. + */ +export interface CheckAccountAccessResult { + hasAccess: boolean; + /** The entity type of the target account (only set when hasAccess is true). */ + entityType?: AccountEntityType; +} + +/** + * Check if an authenticated account can access a target account. + * + * Tries all access paths in order: + * 1. Self-access (target === caller) + * 2. Artist access (via account_artist_ids or shared org) + * 3. Workspace access (via account_workspace_ids) + * 4. Organization access (caller is a member of the target org) + * + * Returns the first match with the entity type, or { hasAccess: false } if none. + * Fails closed: any database error results in denied access. + * + * @param authenticatedAccountId - The caller's account ID + * @param targetAccountId - The account ID being accessed + * @returns Access result with entity type + */ +export async function checkAccountAccess( + authenticatedAccountId: string, + targetAccountId: string, +): Promise { + // 1. Self-access — the caller is accessing their own account + if (targetAccountId === authenticatedAccountId) { + return { hasAccess: true, entityType: "self" }; + } + + // 2. Artist access — target is an artist the caller manages + const isArtist = await checkAccountArtistAccess(authenticatedAccountId, targetAccountId); + if (isArtist) { + return { hasAccess: true, entityType: "artist" }; + } + + // 3. Workspace access — target is a workspace the caller owns + const isWorkspace = await checkAccountWorkspaceAccess(authenticatedAccountId, targetAccountId); + if (isWorkspace) { + return { hasAccess: true, entityType: "workspace" }; + } + + // 4. Organization access — target is an org the caller belongs to + const isOrg = await validateOrganizationAccess({ + accountId: authenticatedAccountId, + organizationId: targetAccountId, + }); + if (isOrg) { + return { hasAccess: true, entityType: "organization" }; + } + + return { hasAccess: false }; +} diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts index 95f038e4..7968b45b 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -60,15 +60,19 @@ describe("validateAuthorizeConnectorBody", () => { expect(response.status).toBe(400); }); - it("should return 400 when account_id is provided but connector is not allowed", () => { + it("should accept any connector with account_id (restriction enforced at request level)", () => { + // Connector restriction for artists is now checked in validateAuthorizeConnectorRequest + // after the entity type is determined, not at the body validation level. const result = validateAuthorizeConnectorBody({ connector: "googlesheets", account_id: "550e8400-e29b-41d4-a716-446655440000", }); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); }); it("should return 400 for invalid callback_url format", () => { diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index 5a31bbeb..9358c3dc 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -3,14 +3,14 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ - checkAccountArtistAccess: vi.fn(), +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -60,7 +60,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); }); - it("should return account_id as composioEntityId when account_id provided", async () => { + it("should allow tiktok for artist account_id", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -68,7 +68,7 @@ describe("validateAuthorizeConnectorRequest", () => { orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", @@ -76,7 +76,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); const result = await validateAuthorizeConnectorRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ composioEntityId: mockEntityId, @@ -86,6 +86,77 @@ describe("validateAuthorizeConnectorRequest", () => { }); }); + it("should return 400 when artist tries to authorize a non-allowed connector", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should allow any connector for workspace account_id", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockEntityId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + + it("should allow any connector for organization account_id", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "organization" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockEntityId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; @@ -94,7 +165,7 @@ describe("validateAuthorizeConnectorRequest", () => { orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", @@ -118,7 +189,7 @@ describe("validateAuthorizeConnectorRequest", () => { orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts index fb5dd7ac..3e5e0920 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -3,15 +3,15 @@ import { NextRequest, NextResponse } from "next/server"; import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ - checkAccountArtistAccess: vi.fn(), +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), })); vi.mock("../verifyConnectorOwnership", () => ({ @@ -84,14 +84,14 @@ describe("validateDisconnectConnectorRequest", () => { expect(response.status).toBe(403); }); - it("should check entity access when account_id provided", async () => { + it("should check account access when account_id provided (artist)", async () => { const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", @@ -99,7 +99,7 @@ describe("validateDisconnectConnectorRequest", () => { }); const result = await validateDisconnectConnectorRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockEntityId); expect(verifyConnectorOwnership).not.toHaveBeenCalled(); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ @@ -108,14 +108,33 @@ describe("validateDisconnectConnectorRequest", () => { }); }); - it("should return 403 when entity access denied", async () => { + it("should check account access when account_id provided (workspace)", async () => { const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockEntityId); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("should return 403 when account access denied", async () => { + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index 33ddb5dd..73b91a90 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -3,14 +3,14 @@ import { NextRequest, NextResponse } from "next/server"; import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ - checkAccountArtistAccess: vi.fn(), +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -52,7 +52,7 @@ describe("validateGetConnectorsRequest", () => { }); }); - it("should return account_id as composioEntityId with allowedToolkits when account_id provided", async () => { + it("should restrict to artist connectors when account_id is an artist", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -60,12 +60,12 @@ describe("validateGetConnectorsRequest", () => { orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); - expect(checkAccountArtistAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ composioEntityId: mockEntityId, @@ -73,6 +73,65 @@ describe("validateGetConnectorsRequest", () => { }); }); + it("should NOT restrict connectors when account_id is a workspace", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); + + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); + const result = await validateGetConnectorsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockEntityId, + allowedToolkits: undefined, + }); + }); + + it("should NOT restrict connectors when account_id is an organization", async () => { + const mockAccountId = "account-123"; + const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "organization" }); + + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); + const result = await validateGetConnectorsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockEntityId, + allowedToolkits: undefined, + }); + }); + + it("should NOT restrict connectors when account_id is self", async () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); + + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockAccountId}`); + const result = await validateGetConnectorsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockAccountId, + allowedToolkits: undefined, + }); + }); + it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; @@ -81,7 +140,7 @@ describe("validateGetConnectorsRequest", () => { orgId: null, authToken: "test-token", }); - vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); const result = await validateGetConnectorsRequest(request); diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts index 299b19a1..88036ae9 100644 --- a/lib/composio/connectors/disconnectConnectorHandler.ts +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -36,7 +36,7 @@ export async function disconnectConnectorHandler(request: NextRequest): Promise< await disconnectConnector(connectedAccountId); } - return NextResponse.json({ success: true }, { status: 200, headers }); + return NextResponse.json({ success: true, message: "Connector disconnected" }, { status: 200, headers }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to disconnect connector"; return NextResponse.json({ error: message }, { status: 500, headers }); diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index 54433d89..415917c4 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -1,44 +1,23 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; -export const authorizeConnectorBodySchema = z - .object({ - connector: z - .string({ message: "connector is required" }) - .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), - callback_url: z.string().url("callback_url must be a valid URL").optional(), - account_id: z.string().uuid("account_id must be a valid UUID").optional(), - }) - .refine( - data => { - // connector must be in ALLOWED_ARTIST_CONNECTORS when account_id is provided - if (data.account_id) { - return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(data.connector); - } - return true; - }, - { - message: `Connector is not allowed for this entity. Allowed: ${ALLOWED_ARTIST_CONNECTORS.join(", ")}`, - path: ["connector"], - }, - ); +export const authorizeConnectorBodySchema = z.object({ + connector: z + .string({ message: "connector is required" }) + .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), + callback_url: z.string().url("callback_url must be a valid URL").optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); export type AuthorizeConnectorBody = z.infer; /** * Validates request body for POST /api/connectors/authorize. * - * - User connection: { connector: "googlesheets" } - * - Entity connection: { connector: "tiktok", account_id: "account-uuid" } - * - * When account_id is provided: - * - Uses that account ID as the Composio entity - * - Validates connector is allowed for that entity type - * - * When account_id is not provided: - * - Uses the authenticated account ID + * Validates structure only (connector, callback_url, account_id). + * Connector restriction for artists is enforced in validateAuthorizeConnectorRequest + * after the entity type is determined via the access check. * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. diff --git a/lib/composio/connectors/validateAuthorizeConnectorRequest.ts b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts index 8a236e0f..70633b83 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorRequest.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts @@ -3,7 +3,9 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAuthorizeConnectorBody } from "./validateAuthorizeConnectorBody"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; +import { isAllowedArtistConnector } from "./isAllowedArtistConnector"; +import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; /** * Validated params for authorizing a connector. @@ -38,7 +40,7 @@ export async function validateAuthorizeConnectorRequest( } const { accountId } = authResult; - // 2. Validate body (includes allowed connector check when account_id is provided) + // 2. Validate body structure const body = await request.json(); const validated = validateAuthorizeConnectorBody(body); if (validated instanceof NextResponse) { @@ -48,9 +50,17 @@ export async function validateAuthorizeConnectorRequest( // 3. If account_id is provided, verify access and use that entity if (account_id) { - const hasAccess = await checkAccountArtistAccess(accountId, account_id); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); + } + + // Artists can only authorize specific connectors (prevents tool collision) + if (accessResult.entityType === "artist" && !isAllowedArtistConnector(connector)) { + return NextResponse.json( + { error: `Connector "${connector}" is not allowed for artists. Allowed: ${ALLOWED_ARTIST_CONNECTORS.join(", ")}` }, + { status: 400, headers }, + ); } // Build auth configs for custom OAuth diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts index 34bf5f72..23aeffef 100644 --- a/lib/composio/connectors/validateDisconnectConnectorRequest.ts +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateDisconnectConnectorBody } from "./validateDisconnectConnectorBody"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; /** @@ -47,10 +47,10 @@ export async function validateDisconnectConnectorRequest( // 3. Verify access if (account_id) { - // Disconnecting for another entity - verify access to that entity - const hasAccess = await checkAccountArtistAccess(accountId, account_id); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); + // Disconnecting for another account - verify access to that account + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); } } else { // Disconnecting account's own connection - verify ownership diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index c141500b..7bd06a3f 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateGetConnectorsQuery } from "./validateGetConnectorsQuery"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; /** @@ -47,14 +47,15 @@ export async function validateGetConnectorsRequest( // 3. If account_id is provided, verify access and use that entity if (account_id) { - const hasAccess = await checkAccountArtistAccess(accountId, account_id); - if (!hasAccess) { - return NextResponse.json({ error: "Access denied to this entity" }, { status: 403, headers }); + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); } return { composioEntityId: account_id, - allowedToolkits: ALLOWED_ARTIST_CONNECTORS, + // Only restrict to artist-specific connectors when the target is an artist + allowedToolkits: accessResult.entityType === "artist" ? ALLOWED_ARTIST_CONNECTORS : undefined, }; } diff --git a/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts b/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts new file mode 100644 index 00000000..40334f6e --- /dev/null +++ b/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts @@ -0,0 +1,31 @@ +import supabase from "../serverClient"; + +/** + * Check if an account has access to a specific workspace. + * + * Access is granted if: + * 1. Account has direct ownership via account_workspace_ids + * + * Fails closed: returns false on any database error to deny access safely. + * + * @param accountId - The account ID to check + * @param workspaceId - The workspace ID to check access for + * @returns true if the account has access to the workspace, false otherwise + */ +export async function checkAccountWorkspaceAccess( + accountId: string, + workspaceId: string, +): Promise { + const { data, error } = await supabase + .from("account_workspace_ids") + .select("workspace_id") + .eq("account_id", accountId) + .eq("workspace_id", workspaceId) + .maybeSingle(); + + if (error) { + return false; // Fail closed + } + + return !!data; +} From 1d223fa1436853bb1f3ce04471fc4a520cc0a2c6 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:46:48 -0500 Subject: [PATCH 31/42] docs: add terminology and flat response shape rules to CLAUDE.md --- CLAUDE.md | 19 +++++++++++++++++++ .../__tests__/getConnectorsHandler.test.ts | 4 ++-- .../connectors/getConnectorsHandler.ts | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a64148a9..1649398e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,25 @@ export async function selectTableName({ - All API routes should have JSDoc comments - Run `pnpm lint` before committing +### Terminology + +Use **"account"** terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name: + +- ✅ `account_id`, "artist", "workspace", "organization" +- ❌ `entity_id`, "entity", "user" + +### API Response Shapes + +Keep response bodies **flat** — put fields at the root level, not nested inside a `data` wrapper: + +```typescript +// ✅ Correct — flat response +{ success: true, connectors: [...] } + +// ❌ Wrong — unnecessary nesting +{ success: true, data: { connectors: [...] } } +``` + ## Test-Driven Development (TDD) **CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.** diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index 29acbecf..26b7af28 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -49,8 +49,8 @@ describe("getConnectorsHandler", () => { expect(result.status).toBe(200); expect(body.success).toBe(true); - expect(body.data.connectors).toHaveLength(2); - expect(body.data.connectors[0].slug).toBe("googlesheets"); + expect(body.connectors).toHaveLength(2); + expect(body.connectors[0].slug).toBe("googlesheets"); }); it("should pass allowedToolkits when account_id is provided", async () => { diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index ec65fa17..e9366298 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -44,7 +44,7 @@ export async function getConnectorsHandler(request: NextRequest): Promise Date: Tue, 10 Feb 2026 17:22:00 -0500 Subject: [PATCH 32/42] refactor: move connector restrictions from API to tool router level - API is now unopinionated: any account can connect any service - Tool router handles collision prevention in createToolRouterSession - Account connections always win over artist connections (no duplicates) - Remove allowedToolkits from GET/authorize validation - Update tests for new architecture --- .../__tests__/getConnectorsHandler.test.ts | 5 +- .../validateAuthorizeConnectorRequest.test.ts | 13 ++-- .../validateGetConnectorsRequest.test.ts | 62 +------------------ .../connectors/getConnectorsHandler.ts | 5 +- .../validateAuthorizeConnectorRequest.ts | 16 ++--- .../validateGetConnectorsRequest.ts | 16 ++--- .../__tests__/createToolRouterSession.test.ts | 40 ++++++++++-- .../toolRouter/createToolRouterSession.ts | 36 ++++++++++- 8 files changed, 95 insertions(+), 98 deletions(-) diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index 26b7af28..5c90c2a5 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -53,10 +53,9 @@ describe("getConnectorsHandler", () => { expect(body.connectors[0].slug).toBe("googlesheets"); }); - it("should pass allowedToolkits when account_id is provided", async () => { + it("should fetch all connectors for any account (no filtering)", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ composioEntityId: "entity-456", - allowedToolkits: ["tiktok"], }); vi.mocked(getConnectors).mockResolvedValue([ @@ -68,8 +67,8 @@ describe("getConnectorsHandler", () => { ); await getConnectorsHandler(request); + // API is unopinionated — no allowedToolkits filtering expect(getConnectors).toHaveBeenCalledWith("entity-456", { - allowedToolkits: ["tiktok"], displayNames: { tiktok: "TikTok", googlesheets: "Google Sheets", diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index 9358c3dc..88191b8e 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -86,7 +86,7 @@ describe("validateAuthorizeConnectorRequest", () => { }); }); - it("should return 400 when artist tries to authorize a non-allowed connector", async () => { + it("should allow any connector for any account type (unopinionated)", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -102,9 +102,14 @@ describe("validateAuthorizeConnectorRequest", () => { }); const result = await validateAuthorizeConnectorRequest(request); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); + // API is unopinionated — artists can connect any service + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + composioEntityId: mockEntityId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); }); it("should allow any connector for workspace account_id", async () => { diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index 73b91a90..0303f69f 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -52,7 +52,7 @@ describe("validateGetConnectorsRequest", () => { }); }); - it("should restrict to artist connectors when account_id is an artist", async () => { + it("should return all connectors for any account type (unopinionated)", async () => { const mockAccountId = "account-123"; const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -69,66 +69,6 @@ describe("validateGetConnectorsRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ composioEntityId: mockEntityId, - allowedToolkits: ["tiktok"], - }); - }); - - it("should NOT restrict connectors when account_id is a workspace", async () => { - const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, - orgId: null, - authToken: "test-token", - }); - vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); - - const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); - const result = await validateGetConnectorsRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - composioEntityId: mockEntityId, - allowedToolkits: undefined, - }); - }); - - it("should NOT restrict connectors when account_id is an organization", async () => { - const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, - orgId: null, - authToken: "test-token", - }); - vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "organization" }); - - const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); - const result = await validateGetConnectorsRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - composioEntityId: mockEntityId, - allowedToolkits: undefined, - }); - }); - - it("should NOT restrict connectors when account_id is self", async () => { - const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, - orgId: null, - authToken: "test-token", - }); - vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "self" }); - - const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockAccountId}`); - const result = await validateGetConnectorsRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - composioEntityId: mockAccountId, - allowedToolkits: undefined, }); }); diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index e9366298..878018c0 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -33,11 +33,10 @@ export async function getConnectorsHandler(request: NextRequest): Promise = {}; if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index 7bd06a3f..01ef9021 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -4,19 +4,21 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateGetConnectorsQuery } from "./validateGetConnectorsQuery"; import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; -import { ALLOWED_ARTIST_CONNECTORS } from "./isAllowedArtistConnector"; /** * Validated params for getting connectors. */ export interface GetConnectorsParams { composioEntityId: string; - allowedToolkits?: readonly string[]; } /** * Validates the full GET /api/connectors request. * + * Unopinionated: returns all available connectors for any account type. + * Connector restrictions (e.g., which tools the AI agent uses) are handled + * at the tool router level, not the API level. + * * Handles: * 1. Authentication (x-api-key or Bearer token) * 2. Query param validation (account_id) @@ -52,15 +54,9 @@ export async function validateGetConnectorsRequest( return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); } - return { - composioEntityId: account_id, - // Only restrict to artist-specific connectors when the target is an artist - allowedToolkits: accessResult.entityType === "artist" ? ALLOWED_ARTIST_CONNECTORS : undefined, - }; + return { composioEntityId: account_id }; } // No account_id: use the authenticated account - return { - composioEntityId: accountId, - }; + return { composioEntityId: accountId }; } diff --git a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts index 63fc3466..802de374 100644 --- a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts +++ b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts @@ -3,6 +3,7 @@ import { createToolRouterSession } from "../createToolRouterSession"; import { getComposioClient } from "../../client"; import { getCallbackUrl } from "../../getCallbackUrl"; +import { getConnectors } from "../../connectors/getConnectors"; vi.mock("../../client", () => ({ getComposioClient: vi.fn(), @@ -12,6 +13,10 @@ vi.mock("../../getCallbackUrl", () => ({ getCallbackUrl: vi.fn(), })); +vi.mock("../../connectors/getConnectors", () => ({ + getConnectors: vi.fn(), +})); + describe("createToolRouterSession", () => { const mockSession = { tools: vi.fn() }; const mockComposio = { create: vi.fn(() => mockSession) }; @@ -20,6 +25,8 @@ describe("createToolRouterSession", () => { vi.clearAllMocks(); vi.mocked(getComposioClient).mockResolvedValue(mockComposio); vi.mocked(getCallbackUrl).mockReturnValue("https://example.com/chat?connected=true"); + // Default: account has no connections + vi.mocked(getConnectors).mockResolvedValue([]); }); it("should create session with enabled toolkits", async () => { @@ -44,19 +51,42 @@ describe("createToolRouterSession", () => { }); }); - it("should pass artist connections when provided", async () => { - const artistConnections = { - tiktok: "tiktok-account-789", - }; + it("should pass artist connections when account has no overlap", async () => { + // Account has Google Sheets connected but NOT TikTok + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "googlesheets", name: "Google Sheets", isConnected: true, connectedAccountId: "gs-123" }, + { slug: "tiktok", name: "TikTok", isConnected: false }, + ]); + + const artistConnections = { tiktok: "artist-tiktok-789" }; + await createToolRouterSession("account-123", undefined, artistConnections); + + // Artist's TikTok should pass through (account doesn't have it connected) + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: { tiktok: "artist-tiktok-789" }, + }); + }); + + it("should filter out artist connections that overlap with account", async () => { + // Account already has TikTok connected + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "account-tiktok" }, + ]); + const artistConnections = { tiktok: "artist-tiktok-789" }; await createToolRouterSession("account-123", undefined, artistConnections); + // Artist's TikTok should be filtered out (account already has it) expect(mockComposio.create).toHaveBeenCalledWith("account-123", { toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], manageConnections: { callbackUrl: "https://example.com/chat?connected=true", }, - connectedAccounts: artistConnections, + connectedAccounts: undefined, }); }); diff --git a/lib/composio/toolRouter/createToolRouterSession.ts b/lib/composio/toolRouter/createToolRouterSession.ts index f1a70dd4..2ecf2ec7 100644 --- a/lib/composio/toolRouter/createToolRouterSession.ts +++ b/lib/composio/toolRouter/createToolRouterSession.ts @@ -1,5 +1,6 @@ import { getComposioClient } from "../client"; import { getCallbackUrl } from "../getCallbackUrl"; +import { getConnectors } from "../connectors/getConnectors"; /** * Toolkits available in Tool Router sessions. @@ -10,6 +11,13 @@ const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"] /** * Create a Composio Tool Router session for an account. * + * This is the opinionated layer — it decides which connections the AI agent uses. + * When both the account and artist have the same toolkit connected, the account's + * connection is kept and the artist's is dropped to prevent tool collision + * (the AI wouldn't know which credentials to use). + * + * Artist connections only fill gaps where the account has no connection. + * * @param accountId - Unique identifier for the account * @param roomId - Optional chat room ID for OAuth redirect * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections @@ -26,12 +34,38 @@ export async function createToolRouterSession( roomId, }); + // Filter artist connections to prevent tool collision. + // If the account already has a toolkit connected, the account's connection wins. + // Artist connections only override toolkits the account hasn't connected. + let filteredConnections = artistConnections; + + if (artistConnections && Object.keys(artistConnections).length > 0) { + const accountConnectors = await getConnectors(accountId); + + // Find which toolkits the account already has active connections for + const accountConnectedSlugs = new Set( + accountConnectors.filter(c => c.isConnected).map(c => c.slug), + ); + + // Only keep artist connections for toolkits the account doesn't have + filteredConnections = Object.fromEntries( + Object.entries(artistConnections).filter( + ([slug]) => !accountConnectedSlugs.has(slug), + ), + ); + + // If nothing left after filtering, don't pass overrides at all + if (Object.keys(filteredConnections).length === 0) { + filteredConnections = undefined; + } + } + const session = await composio.create(accountId, { toolkits: ENABLED_TOOLKITS, manageConnections: { callbackUrl, }, - connectedAccounts: artistConnections, + connectedAccounts: filteredConnections, }); return session; From f9ef3206630261a6c737bb2789a2d20d67ed8022 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:18:04 -0500 Subject: [PATCH 33/42] refactor: move POST /api/connectors/authorize to POST /api/connectors Consolidates the authorize endpoint into the main connectors route to match the updated API docs. Co-Authored-By: Claude Opus 4.6 --- app/api/connectors/authorize/route.ts | 33 ------------------- app/api/connectors/route.ts | 20 +++++++++++ .../authorizeConnectorHandler.test.ts | 10 +++--- .../connectors/authorizeConnectorHandler.ts | 2 +- 4 files changed, 26 insertions(+), 39 deletions(-) delete mode 100644 app/api/connectors/authorize/route.ts diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts deleted file mode 100644 index f29a71b6..00000000 --- a/app/api/connectors/authorize/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { authorizeConnectorHandler } from "@/lib/composio/connectors/authorizeConnectorHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/connectors/authorize - * - * Generate an OAuth authorization URL for a specific connector. - * - * Authentication: x-api-key OR Authorization Bearer token required. - * - * Request body: - * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) - * - callback_url: Optional custom callback URL after OAuth - * - account_id: Optional entity ID (e.g., artist ID) for entity-specific connections - * - * @param request - * @returns The redirect URL for OAuth authorization - */ -export async function POST(request: NextRequest) { - return authorizeConnectorHandler(request); -} diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index cc842b57..9a20d83a 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getConnectorsHandler } from "@/lib/composio/connectors/getConnectorsHandler"; +import { authorizeConnectorHandler } from "@/lib/composio/connectors/authorizeConnectorHandler"; import { disconnectConnectorHandler } from "@/lib/composio/connectors/disconnectConnectorHandler"; /** @@ -31,6 +32,25 @@ export async function GET(request: NextRequest) { return getConnectorsHandler(request); } +/** + * POST /api/connectors + * + * Generate an OAuth authorization URL for a specific connector. + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * Request body: + * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) + * - callback_url: Optional custom callback URL after OAuth + * - account_id: Optional account ID for account-specific connections + * + * @param request + * @returns The redirect URL for OAuth authorization + */ +export async function POST(request: NextRequest) { + return authorizeConnectorHandler(request); +} + /** * DELETE /api/connectors * diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts index 7a5a3bf1..aa9ac5e7 100644 --- a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -27,7 +27,7 @@ describe("authorizeConnectorHandler", () => { NextResponse.json({ error: "Invalid request" }, { status: 400 }), ); - const request = new NextRequest("http://localhost/api/connectors/authorize", { + const request = new NextRequest("http://localhost/api/connectors", { method: "POST", }); const result = await authorizeConnectorHandler(request); @@ -45,7 +45,7 @@ describe("authorizeConnectorHandler", () => { redirectUrl: "https://oauth.example.com/auth", }); - const request = new NextRequest("http://localhost/api/connectors/authorize", { + const request = new NextRequest("http://localhost/api/connectors", { method: "POST", }); const result = await authorizeConnectorHandler(request); @@ -71,7 +71,7 @@ describe("authorizeConnectorHandler", () => { redirectUrl: "https://oauth.example.com/auth", }); - const request = new NextRequest("http://localhost/api/connectors/authorize", { + const request = new NextRequest("http://localhost/api/connectors", { method: "POST", }); await authorizeConnectorHandler(request); @@ -93,7 +93,7 @@ describe("authorizeConnectorHandler", () => { redirectUrl: "https://oauth.example.com/auth", }); - const request = new NextRequest("http://localhost/api/connectors/authorize", { + const request = new NextRequest("http://localhost/api/connectors", { method: "POST", }); await authorizeConnectorHandler(request); @@ -111,7 +111,7 @@ describe("authorizeConnectorHandler", () => { }); vi.mocked(authorizeConnector).mockRejectedValue(new Error("OAuth failed")); - const request = new NextRequest("http://localhost/api/connectors/authorize", { + const request = new NextRequest("http://localhost/api/connectors", { method: "POST", }); const result = await authorizeConnectorHandler(request); diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index e1a45554..0c01906d 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -5,7 +5,7 @@ import { validateAuthorizeConnectorRequest } from "./validateAuthorizeConnectorR import { authorizeConnector } from "./authorizeConnector"; /** - * Handler for POST /api/connectors/authorize. + * Handler for POST /api/connectors. * * Generates an OAuth authorization URL for a specific connector. * Supports connecting for the authenticated account or another entity (via account_id). From 20389c62c42689230ac07dfb6bf0c1658f9c7636 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:23:27 -0500 Subject: [PATCH 34/42] refactor: extract standalone supabase queries from checkAccountArtistAccess SRP: each query is now its own file with dedicated tests: - selectAccountArtistId (account_artist_ids) - selectArtistOrganizationIds (artist_organization_ids) - selectAccountOrganizationIds (account_organization_ids) Co-Authored-By: Claude Opus 4.6 --- .../checkAccountArtistAccess.test.ts | 162 +++++------------- .../__tests__/selectAccountArtistId.test.ts | 70 ++++++++ .../checkAccountArtistAccess.ts | 47 ++--- .../selectAccountArtistId.ts | 26 +++ .../selectAccountOrganizationIds.test.ts | 77 +++++++++ .../selectAccountOrganizationIds.ts | 28 +++ .../selectArtistOrganizationIds.test.ts | 58 +++++++ .../selectArtistOrganizationIds.ts | 20 +++ 8 files changed, 337 insertions(+), 151 deletions(-) create mode 100644 lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts create mode 100644 lib/supabase/account_artist_ids/selectAccountArtistId.ts create mode 100644 lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts create mode 100644 lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts create mode 100644 lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts create mode 100644 lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts diff --git a/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts b/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts index 1bd26825..c3159287 100644 --- a/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts +++ b/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts @@ -1,14 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - return { - default: { from: mockFrom }, - }; -}); +vi.mock("../selectAccountArtistId", () => ({ + selectAccountArtistId: vi.fn(), +})); + +vi.mock("../../artist_organization_ids/selectArtistOrganizationIds", () => ({ + selectArtistOrganizationIds: vi.fn(), +})); + +vi.mock("../../account_organization_ids/selectAccountOrganizationIds", () => ({ + selectAccountOrganizationIds: vi.fn(), +})); -import supabase from "../../serverClient"; +import { selectAccountArtistId } from "../selectAccountArtistId"; +import { selectArtistOrganizationIds } from "../../artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "../../account_organization_ids/selectAccountOrganizationIds"; describe("checkAccountArtistAccess", () => { beforeEach(() => { @@ -16,98 +23,34 @@ describe("checkAccountArtistAccess", () => { }); it("should return true when account has direct access to artist", async () => { - const mockSelect = vi.fn().mockReturnThis(); - const mockEq = vi.fn().mockReturnThis(); - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: { artist_id: "artist-123" }, - error: null, - }); - - vi.mocked(supabase.from).mockReturnValue({ - select: mockSelect, - eq: mockEq, - maybeSingle: mockMaybeSingle, - } as never); - - mockSelect.mockReturnThis(); - mockEq.mockReturnValue({ eq: mockEq, maybeSingle: mockMaybeSingle }); + vi.mocked(selectAccountArtistId).mockResolvedValue({ artist_id: "artist-123" }); const result = await checkAccountArtistAccess("account-123", "artist-123"); - expect(supabase.from).toHaveBeenCalledWith("account_artist_ids"); + expect(selectAccountArtistId).toHaveBeenCalledWith("account-123", "artist-123"); expect(result).toBe(true); + expect(selectArtistOrganizationIds).not.toHaveBeenCalled(); }); it("should return true when account and artist share an organization", async () => { - // First call - direct access check (returns null) - const mockDirectAccess = vi.fn().mockResolvedValue({ - data: null, - error: null, - }); - - // Second call - artist orgs - const mockArtistOrgs = vi.fn().mockResolvedValue({ - data: [{ organization_id: "org-1" }], - error: null, - }); - - // Third call - user org access - const mockUserOrgAccess = vi.fn().mockResolvedValue({ - data: [{ organization_id: "org-1" }], - error: null, - }); - - let callCount = 0; - vi.mocked(supabase.from).mockImplementation((table: string) => { - callCount++; - if (table === "account_artist_ids") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - maybeSingle: mockDirectAccess, - }), - }), - }), - } as never; - } else if (table === "artist_organization_ids") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue(mockArtistOrgs()), - }), - } as never; - } else if (table === "account_organization_ids") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - in: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue(mockUserOrgAccess()), - }), - }), - }), - } as never; - } - return {} as never; - }); + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); + vi.mocked(selectAccountOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); const result = await checkAccountArtistAccess("account-123", "artist-456"); + expect(selectArtistOrganizationIds).toHaveBeenCalledWith("artist-456"); + expect(selectAccountOrganizationIds).toHaveBeenCalledWith("account-123", ["org-1"]); expect(result).toBe(true); }); - it("should return false when direct access check errors (fail closed)", async () => { - vi.mocked(supabase.from).mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - maybeSingle: vi.fn().mockResolvedValue({ - data: null, - error: new Error("Database error"), - }), - }), - }), - }), - } as never); + it("should return false when artist org lookup errors (fail closed)", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue(null); const result = await checkAccountArtistAccess("account-123", "artist-123"); @@ -115,38 +58,21 @@ describe("checkAccountArtistAccess", () => { }); it("should return false when account has no access", async () => { - // Direct access - none - const mockDirectAccess = vi.fn().mockResolvedValue({ - data: null, - error: null, - }); - - // Artist has no orgs - const mockArtistOrgs = vi.fn().mockResolvedValue({ - data: [], - error: null, - }); - - vi.mocked(supabase.from).mockImplementation((table: string) => { - if (table === "account_artist_ids") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - maybeSingle: mockDirectAccess, - }), - }), - }), - } as never; - } else if (table === "artist_organization_ids") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue(mockArtistOrgs()), - }), - } as never; - } - return {} as never; - }); + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([]); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(result).toBe(false); + expect(selectAccountOrganizationIds).not.toHaveBeenCalled(); + }); + + it("should return false when account org lookup errors (fail closed)", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); + vi.mocked(selectAccountOrganizationIds).mockResolvedValue(null); const result = await checkAccountArtistAccess("account-123", "artist-456"); diff --git a/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts b/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts new file mode 100644 index 00000000..12c6935b --- /dev/null +++ b/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountArtistId } from "../selectAccountArtistId"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountArtistId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the row when account has direct access to artist", async () => { + const row = { artist_id: "artist-123" }; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: row, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(supabase.from).toHaveBeenCalledWith("account_artist_ids"); + expect(result).toEqual(row); + }); + + it("should return null when no direct access exists", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(result).toBeNull(); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts b/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts index 3590eb65..f1424456 100644 --- a/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts +++ b/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts @@ -1,4 +1,6 @@ -import supabase from "../serverClient"; +import { selectAccountArtistId } from "./selectAccountArtistId"; +import { selectArtistOrganizationIds } from "../artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "../account_organization_ids/selectAccountOrganizationIds"; /** * Check if an account has access to a specific artist. @@ -18,46 +20,25 @@ export async function checkAccountArtistAccess( artistId: string, ): Promise { // 1. Check direct access via account_artist_ids - const { data: directAccess, error: directError } = await supabase - .from("account_artist_ids") - .select("artist_id") - .eq("account_id", accountId) - .eq("artist_id", artistId) - .maybeSingle(); - - if (directError) { - return false; // Fail closed - } + const directAccess = await selectAccountArtistId(accountId, artistId); if (directAccess) return true; - // 2. Check organization access: user and artist share an org - // Get all orgs the artist belongs to - const { data: artistOrgs, error: artistOrgsError } = await supabase - .from("artist_organization_ids") - .select("organization_id") - .eq("artist_id", artistId); + // 2. Check organization access: account and artist share an org + const artistOrgs = await selectArtistOrganizationIds(artistId); - if (artistOrgsError) { - return false; // Fail closed - } + if (!artistOrgs) return false; // Fail closed on error - if (!artistOrgs?.length) return false; + if (!artistOrgs.length) return false; - // Check if user belongs to any of those orgs - const orgIds = artistOrgs.map(o => o.organization_id).filter((id): id is string => Boolean(id)); + const orgIds = artistOrgs + .map((o) => o.organization_id) + .filter((id): id is string => Boolean(id)); if (!orgIds.length) return false; - const { data: userOrgAccess, error: userOrgError } = await supabase - .from("account_organization_ids") - .select("organization_id") - .eq("account_id", accountId) - .in("organization_id", orgIds) - .limit(1); + const userOrgAccess = await selectAccountOrganizationIds(accountId, orgIds); - if (userOrgError) { - return false; // Fail closed - } + if (!userOrgAccess) return false; // Fail closed on error - return !!userOrgAccess?.length; + return !!userOrgAccess.length; } diff --git a/lib/supabase/account_artist_ids/selectAccountArtistId.ts b/lib/supabase/account_artist_ids/selectAccountArtistId.ts new file mode 100644 index 00000000..8031957e --- /dev/null +++ b/lib/supabase/account_artist_ids/selectAccountArtistId.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; + +/** + * Select a single account_artist_ids row for a specific account and artist. + * + * @param accountId - The account ID + * @param artistId - The artist ID + * @returns The row if found, null if not found or on error + */ +export async function selectAccountArtistId( + accountId: string, + artistId: string, +) { + const { data, error } = await supabase + .from("account_artist_ids") + .select("artist_id") + .eq("account_id", accountId) + .eq("artist_id", artistId) + .maybeSingle(); + + if (error) { + return null; + } + + return data; +} diff --git a/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts b/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts new file mode 100644 index 00000000..d4c7a595 --- /dev/null +++ b/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountOrganizationIds } from "../selectAccountOrganizationIds"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountOrganizationIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return matching organization IDs for an account", async () => { + const rows = [{ organization_id: "org-1" }]; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: rows, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1", "org-2"]); + + expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); + expect(result).toEqual(rows); + }); + + it("should return empty array when account has no matching orgs", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1"]); + + expect(result).toEqual([]); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1"]); + + expect(result).toBeNull(); + }); + + it("should return empty array when orgIds is empty", async () => { + const result = await selectAccountOrganizationIds("account-123", []); + + expect(supabase.from).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts b/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts new file mode 100644 index 00000000..68fd6618 --- /dev/null +++ b/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts @@ -0,0 +1,28 @@ +import supabase from "../serverClient"; + +/** + * Select account_organization_ids rows matching an account and a set of organization IDs. + * + * @param accountId - The account ID + * @param orgIds - Organization IDs to check membership against + * @returns Array of matching rows, or null on error + */ +export async function selectAccountOrganizationIds( + accountId: string, + orgIds: string[], +) { + if (!orgIds.length) return []; + + const { data, error } = await supabase + .from("account_organization_ids") + .select("organization_id") + .eq("account_id", accountId) + .in("organization_id", orgIds) + .limit(1); + + if (error) { + return null; + } + + return data || []; +} diff --git a/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts b/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts new file mode 100644 index 00000000..b1df5714 --- /dev/null +++ b/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectArtistOrganizationIds } from "../selectArtistOrganizationIds"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectArtistOrganizationIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return organization IDs for an artist", async () => { + const rows = [{ organization_id: "org-1" }, { organization_id: "org-2" }]; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: rows, error: null }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(supabase.from).toHaveBeenCalledWith("artist_organization_ids"); + expect(result).toEqual(rows); + }); + + it("should return empty array when artist has no organizations", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(result).toEqual([]); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts b/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts new file mode 100644 index 00000000..71ed39e7 --- /dev/null +++ b/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts @@ -0,0 +1,20 @@ +import supabase from "../serverClient"; + +/** + * Select all organization IDs for a given artist. + * + * @param artistId - The artist ID + * @returns Array of rows with organization_id, or null on error + */ +export async function selectArtistOrganizationIds(artistId: string) { + const { data, error } = await supabase + .from("artist_organization_ids") + .select("organization_id") + .eq("artist_id", artistId); + + if (error) { + return null; + } + + return data || []; +} From ab957830001be717eb4145ede951972dbdedc22f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:26:06 -0500 Subject: [PATCH 35/42] refactor: extract selectAccountWorkspaceId from checkAccountWorkspaceAccess SRP: standalone supabase query with dedicated tests. Co-Authored-By: Claude Opus 4.6 --- .../selectAccountWorkspaceId.test.ts | 70 +++++++++++++++++++ .../checkAccountWorkspaceAccess.ts | 14 +--- .../selectAccountWorkspaceId.ts | 26 +++++++ 3 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts create mode 100644 lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts diff --git a/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts b/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts new file mode 100644 index 00000000..e3611d22 --- /dev/null +++ b/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountWorkspaceId } from "../selectAccountWorkspaceId"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountWorkspaceId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the row when account owns the workspace", async () => { + const row = { workspace_id: "ws-123" }; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: row, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(supabase.from).toHaveBeenCalledWith("account_workspace_ids"); + expect(result).toEqual(row); + }); + + it("should return null when no ownership exists", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(result).toBeNull(); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts b/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts index 40334f6e..a766a9a6 100644 --- a/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts +++ b/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts @@ -1,4 +1,4 @@ -import supabase from "../serverClient"; +import { selectAccountWorkspaceId } from "./selectAccountWorkspaceId"; /** * Check if an account has access to a specific workspace. @@ -16,16 +16,6 @@ export async function checkAccountWorkspaceAccess( accountId: string, workspaceId: string, ): Promise { - const { data, error } = await supabase - .from("account_workspace_ids") - .select("workspace_id") - .eq("account_id", accountId) - .eq("workspace_id", workspaceId) - .maybeSingle(); - - if (error) { - return false; // Fail closed - } - + const data = await selectAccountWorkspaceId(accountId, workspaceId); return !!data; } diff --git a/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts b/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts new file mode 100644 index 00000000..298a1dfb --- /dev/null +++ b/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; + +/** + * Select a single account_workspace_ids row for a specific account and workspace. + * + * @param accountId - The account ID + * @param workspaceId - The workspace ID + * @returns The row if found, null if not found or on error + */ +export async function selectAccountWorkspaceId( + accountId: string, + workspaceId: string, +) { + const { data, error } = await supabase + .from("account_workspace_ids") + .select("workspace_id") + .eq("account_id", accountId) + .eq("workspace_id", workspaceId) + .maybeSingle(); + + if (error) { + return null; + } + + return data; +} From f077d25a5a2c116d76eab480ecb60f4d93c5b7f6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:33:13 -0500 Subject: [PATCH 36/42] refactor: rename entityId to accountId across all connector code Use "account" terminology consistently per codebase conventions. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/authorizeConnector.test.ts | 4 +- .../authorizeConnectorHandler.test.ts | 12 +++--- .../__tests__/disconnectConnector.test.ts | 6 +-- .../disconnectConnectorHandler.test.ts | 8 ++-- .../__tests__/getConnectorsHandler.test.ts | 10 ++--- .../validateAuthorizeConnectorRequest.test.ts | 38 +++++++++---------- ...validateDisconnectConnectorRequest.test.ts | 20 +++++----- .../validateGetConnectorsRequest.test.ts | 16 ++++---- lib/composio/connectors/authorizeConnector.ts | 9 ++--- .../connectors/authorizeConnectorHandler.ts | 6 +-- .../connectors/disconnectConnector.ts | 6 +-- .../connectors/disconnectConnectorHandler.ts | 10 ++--- lib/composio/connectors/getConnectors.ts | 10 ++--- .../connectors/getConnectorsHandler.ts | 4 +- .../validateAuthorizeConnectorRequest.ts | 6 +-- .../validateDisconnectConnectorRequest.ts | 6 +-- .../validateGetConnectorsRequest.ts | 6 +-- 17 files changed, 87 insertions(+), 90 deletions(-) diff --git a/lib/composio/connectors/__tests__/authorizeConnector.test.ts b/lib/composio/connectors/__tests__/authorizeConnector.test.ts index 70b2a30c..1e380949 100644 --- a/lib/composio/connectors/__tests__/authorizeConnector.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnector.test.ts @@ -62,9 +62,9 @@ describe("authorizeConnector", () => { it("should include auth configs when provided", async () => { const authConfigs = { tiktok: "ac_12345" }; - await authorizeConnector("entity-123", "tiktok", { authConfigs }); + await authorizeConnector("account-456", "tiktok", { authConfigs }); - expect(mockCreate).toHaveBeenCalledWith("entity-123", { + expect(mockCreate).toHaveBeenCalledWith("account-456", { authConfigs, manageConnections: { callbackUrl: "https://app.example.com/settings/connectors?connected=true", diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts index aa9ac5e7..f6fce402 100644 --- a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -37,7 +37,7 @@ describe("authorizeConnectorHandler", () => { it("should call authorizeConnector with validated params for account connection", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "account-123", + accountId: "account-123", connector: "googlesheets", }); vi.mocked(authorizeConnector).mockResolvedValue({ @@ -60,9 +60,9 @@ describe("authorizeConnectorHandler", () => { expect(body.data.redirectUrl).toBe("https://oauth.example.com/auth"); }); - it("should call authorizeConnector with authConfigs for entity connection", async () => { + it("should call authorizeConnector with authConfigs for account with authConfigs", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "entity-456", + accountId: "account-456", connector: "tiktok", authConfigs: { tiktok: "ac_123" }, }); @@ -76,7 +76,7 @@ describe("authorizeConnectorHandler", () => { }); await authorizeConnectorHandler(request); - expect(authorizeConnector).toHaveBeenCalledWith("entity-456", "tiktok", { + expect(authorizeConnector).toHaveBeenCalledWith("account-456", "tiktok", { customCallbackUrl: undefined, authConfigs: { tiktok: "ac_123" }, }); @@ -84,7 +84,7 @@ describe("authorizeConnectorHandler", () => { it("should pass through custom callbackUrl", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "account-123", + accountId: "account-123", connector: "googlesheets", callbackUrl: "https://custom.example.com/callback", }); @@ -106,7 +106,7 @@ describe("authorizeConnectorHandler", () => { it("should return 500 on error", async () => { vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ - composioEntityId: "account-123", + accountId: "account-123", connector: "googlesheets", }); vi.mocked(authorizeConnector).mockRejectedValue(new Error("OAuth failed")); diff --git a/lib/composio/connectors/__tests__/disconnectConnector.test.ts b/lib/composio/connectors/__tests__/disconnectConnector.test.ts index 2f7e3161..fb6fe698 100644 --- a/lib/composio/connectors/__tests__/disconnectConnector.test.ts +++ b/lib/composio/connectors/__tests__/disconnectConnector.test.ts @@ -74,18 +74,18 @@ describe("disconnectConnector", () => { disconnectConnector("ca_12345", { verifyOwnershipFor: "artist-456", }), - ).rejects.toThrow("Connection not found for this entity"); + ).rejects.toThrow("Connection not found for this account"); expect(global.fetch).not.toHaveBeenCalled(); }); - it("should throw when entity has no connections", async () => { + it("should throw when account has no connections", async () => { vi.mocked(getConnectors).mockResolvedValue([]); await expect( disconnectConnector("ca_12345", { verifyOwnershipFor: "artist-456", }), - ).rejects.toThrow("Connection not found for this entity"); + ).rejects.toThrow("Connection not found for this account"); }); }); diff --git a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts index 91644c6f..e315420e 100644 --- a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts +++ b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts @@ -35,7 +35,7 @@ describe("disconnectConnectorHandler", () => { expect(result.status).toBe(400); }); - it("should call disconnectConnector without options when no entityId", async () => { + it("should call disconnectConnector without options when no targetAccountId", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ connectedAccountId: "ca_123", }); @@ -52,10 +52,10 @@ describe("disconnectConnectorHandler", () => { expect(body.success).toBe(true); }); - it("should call disconnectConnector with verifyOwnershipFor when entityId provided", async () => { + it("should call disconnectConnector with verifyOwnershipFor when targetAccountId provided", async () => { vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ connectedAccountId: "ca_123", - entityId: "entity-456", + targetAccountId: "account-456", }); vi.mocked(disconnectConnector).mockResolvedValue(undefined); @@ -65,7 +65,7 @@ describe("disconnectConnectorHandler", () => { const result = await disconnectConnectorHandler(request); expect(disconnectConnector).toHaveBeenCalledWith("ca_123", { - verifyOwnershipFor: "entity-456", + verifyOwnershipFor: "account-456", }); expect(result.status).toBe(200); }); diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts index 5c90c2a5..c214bf71 100644 --- a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -35,7 +35,7 @@ describe("getConnectorsHandler", () => { it("should return connectors list for account", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "account-123", + accountId: "account-123", }); vi.mocked(getConnectors).mockResolvedValue([ @@ -55,7 +55,7 @@ describe("getConnectorsHandler", () => { it("should fetch all connectors for any account (no filtering)", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "entity-456", + accountId: "account-456", }); vi.mocked(getConnectors).mockResolvedValue([ @@ -63,12 +63,12 @@ describe("getConnectorsHandler", () => { ]); const request = new NextRequest( - "http://localhost/api/connectors?account_id=entity-456", + "http://localhost/api/connectors?account_id=account-456", ); await getConnectorsHandler(request); // API is unopinionated — no allowedToolkits filtering - expect(getConnectors).toHaveBeenCalledWith("entity-456", { + expect(getConnectors).toHaveBeenCalledWith("account-456", { displayNames: { tiktok: "TikTok", googlesheets: "Google Sheets", @@ -80,7 +80,7 @@ describe("getConnectorsHandler", () => { it("should return 500 when getConnectors throws", async () => { vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ - composioEntityId: "account-123", + accountId: "account-123", }); vi.mocked(getConnectors).mockRejectedValue(new Error("Composio API error")); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts index 88191b8e..87966100 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -38,7 +38,7 @@ describe("validateAuthorizeConnectorRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId as composioEntityId when no account_id", async () => { + it("should return accountId as accountId when no account_id", async () => { const mockAccountId = "account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, @@ -54,7 +54,7 @@ describe("validateAuthorizeConnectorRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockAccountId, + accountId: mockAccountId, connector: "googlesheets", callbackUrl: undefined, }); @@ -62,7 +62,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should allow tiktok for artist account_id", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -72,14 +72,14 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); - expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockTargetAccountId); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockEntityId, + accountId: mockTargetAccountId, connector: "tiktok", callbackUrl: undefined, authConfigs: undefined, @@ -88,7 +88,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should allow any connector for any account type (unopinionated)", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -98,14 +98,14 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); // API is unopinionated — artists can connect any service expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockEntityId, + accountId: mockTargetAccountId, connector: "googlesheets", callbackUrl: undefined, authConfigs: undefined, @@ -114,7 +114,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should allow any connector for workspace account_id", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -124,13 +124,13 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockEntityId, + accountId: mockTargetAccountId, connector: "googlesheets", callbackUrl: undefined, authConfigs: undefined, @@ -139,7 +139,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should allow any connector for organization account_id", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -149,13 +149,13 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "googlesheets", account_id: mockEntityId }), + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockEntityId, + accountId: mockTargetAccountId, connector: "googlesheets", callbackUrl: undefined, authConfigs: undefined, @@ -164,7 +164,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -174,7 +174,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); @@ -185,7 +185,7 @@ describe("validateAuthorizeConnectorRequest", () => { it("should include TikTok auth config when connector is tiktok and env var is set", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; const originalEnv = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = "ac_test123"; @@ -198,7 +198,7 @@ describe("validateAuthorizeConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors/authorize", { method: "POST", - body: JSON.stringify({ connector: "tiktok", account_id: mockEntityId }), + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), }); const result = await validateAuthorizeConnectorRequest(request); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts index 3e5e0920..aa7ed6ca 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -61,7 +61,7 @@ describe("validateDisconnectConnectorRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connectedAccountId: "ca_123", - entityId: undefined, + targetAccountId: undefined, }); }); @@ -85,7 +85,7 @@ describe("validateDisconnectConnectorRequest", () => { }); it("should check account access when account_id provided (artist)", async () => { - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -95,21 +95,21 @@ describe("validateDisconnectConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), }); const result = await validateDisconnectConnectorRequest(request); - expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockTargetAccountId); expect(verifyConnectorOwnership).not.toHaveBeenCalled(); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ connectedAccountId: "ca_123", - entityId: mockEntityId, + targetAccountId: mockTargetAccountId, }); }); it("should check account access when account_id provided (workspace)", async () => { - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -119,16 +119,16 @@ describe("validateDisconnectConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), }); const result = await validateDisconnectConnectorRequest(request); - expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockTargetAccountId); expect(result).not.toBeInstanceOf(NextResponse); }); it("should return 403 when account access denied", async () => { - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-123", orgId: null, @@ -138,7 +138,7 @@ describe("validateDisconnectConnectorRequest", () => { const request = new NextRequest("http://localhost/api/connectors", { method: "DELETE", - body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockEntityId }), + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), }); const result = await validateDisconnectConnectorRequest(request); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts index 0303f69f..5dc514ff 100644 --- a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -35,7 +35,7 @@ describe("validateGetConnectorsRequest", () => { expect(response.status).toBe(401); }); - it("should return accountId as composioEntityId when no account_id provided", async () => { + it("should return accountId as accountId when no account_id provided", async () => { const mockAccountId = "account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, @@ -48,13 +48,13 @@ describe("validateGetConnectorsRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockAccountId, + accountId: mockAccountId, }); }); it("should return all connectors for any account type (unopinionated)", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -62,19 +62,19 @@ describe("validateGetConnectorsRequest", () => { }); vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); - const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockTargetAccountId}`); const result = await validateGetConnectorsRequest(request); - expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockEntityId); + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockTargetAccountId); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ - composioEntityId: mockEntityId, + accountId: mockTargetAccountId, }); }); it("should return 403 when account_id provided but no access", async () => { const mockAccountId = "account-123"; - const mockEntityId = "550e8400-e29b-41d4-a716-446655440000"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId: mockAccountId, orgId: null, @@ -82,7 +82,7 @@ describe("validateGetConnectorsRequest", () => { }); vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); - const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockEntityId}`); + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockTargetAccountId}`); const result = await validateGetConnectorsRequest(request); expect(result).toBeInstanceOf(NextResponse); diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index ec808ad1..7c82e799 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -27,16 +27,13 @@ export interface AuthorizeConnectorOptions { /** * Generate an OAuth authorization URL for a connector. * - * The entityId is an account ID - either the caller's own account or - * another entity (like an artist) they have access to. - * - * @param entityId - The account ID to store the connection under + * @param accountId - The account ID to store the connection under * @param connector - The connector slug (e.g., "googlesheets", "tiktok") * @param options - Authorization options * @returns The redirect URL for OAuth */ export async function authorizeConnector( - entityId: string, + accountId: string, connector: string, options: AuthorizeConnectorOptions = {}, ): Promise { @@ -47,7 +44,7 @@ export async function authorizeConnector( const callbackUrl = customCallbackUrl ?? getCallbackUrl({ destination: "connectors" }); // Create session with optional auth configs - const session = await composio.create(entityId, { + const session = await composio.create(accountId, { ...(authConfigs && Object.keys(authConfigs).length > 0 && { authConfigs }), manageConnections: { callbackUrl, diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts index 0c01906d..081f73b2 100644 --- a/lib/composio/connectors/authorizeConnectorHandler.ts +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -8,7 +8,7 @@ import { authorizeConnector } from "./authorizeConnector"; * Handler for POST /api/connectors. * * Generates an OAuth authorization URL for a specific connector. - * Supports connecting for the authenticated account or another entity (via account_id). + * Supports connecting for the authenticated account or another account (via account_id). * * @param request - The incoming request * @returns The redirect URL for OAuth authorization @@ -23,10 +23,10 @@ export async function authorizeConnectorHandler(request: NextRequest): Promise c.connectedAccountId === connectedAccountId); if (!hasConnection) { - throw new Error("Connection not found for this entity"); + throw new Error("Connection not found for this account"); } } diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts index 88036ae9..02d93526 100644 --- a/lib/composio/connectors/disconnectConnectorHandler.ts +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -8,7 +8,7 @@ import { disconnectConnector } from "./disconnectConnector"; * Handler for DELETE /api/connectors. * * Disconnects a connected account from Composio. - * Supports disconnecting for the authenticated account or another entity (via account_id). + * Supports disconnecting for the authenticated account or another account (via account_id). * * @param request - The incoming request * @returns Success status @@ -23,13 +23,13 @@ export async function disconnectConnectorHandler(request: NextRequest): Promise< return validated; } - const { connectedAccountId, entityId } = validated; + const { connectedAccountId, targetAccountId } = validated; // Disconnect from Composio - if (entityId) { - // Disconnecting for another entity - verify ownership + if (targetAccountId) { + // Disconnecting for another account - verify ownership await disconnectConnector(connectedAccountId, { - verifyOwnershipFor: entityId, + verifyOwnershipFor: targetAccountId, }); } else { // User's own connection - already verified in validation diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index ce71a71b..1a32eee5 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -27,16 +27,16 @@ export interface GetConnectorsOptions { } /** - * Get connectors and their connection status for an entity. + * Get connectors and their connection status for an account. * - * Works for any account ID. Composio uses the entityId to scope connections. + * Works for any account ID. Composio uses the accountId to scope connections. * - * @param entityId - The account ID to get connectors for + * @param accountId - The account ID to get connectors for * @param options - Options for filtering and display * @returns List of connectors with connection status */ export async function getConnectors( - entityId: string, + accountId: string, options: GetConnectorsOptions = {}, ): Promise { const { allowedToolkits, displayNames = {} } = options; @@ -47,7 +47,7 @@ export async function getConnectors( ? { toolkits: [...allowedToolkits] as string[] } : undefined; - const session = await composio.create(entityId, sessionOptions); + const session = await composio.create(accountId, sessionOptions); const toolkits = await session.toolkits(); // Build connector list diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index 878018c0..d92f1293 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -33,10 +33,10 @@ export async function getConnectorsHandler(request: NextRequest): Promise; @@ -64,7 +64,7 @@ export async function validateAuthorizeConnectorRequest( } return { - composioEntityId: account_id, + accountId: account_id, connector, callbackUrl: callback_url, authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, @@ -73,7 +73,7 @@ export async function validateAuthorizeConnectorRequest( // No account_id: use the authenticated account return { - composioEntityId: accountId, + accountId, connector, callbackUrl: callback_url, }; diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts index 23aeffef..6716f285 100644 --- a/lib/composio/connectors/validateDisconnectConnectorRequest.ts +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -11,7 +11,7 @@ import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; */ export interface DisconnectConnectorParams { connectedAccountId: string; - entityId?: string; + targetAccountId?: string; } /** @@ -20,7 +20,7 @@ export interface DisconnectConnectorParams { * Handles: * 1. Authentication (x-api-key or Bearer token) * 2. Body validation (connected_account_id, account_id) - * 3. Access verification (entity access or connector ownership) + * 3. Access verification (account access or connector ownership) * * @param request - The incoming request * @returns NextResponse error or validated params @@ -65,6 +65,6 @@ export async function validateDisconnectConnectorRequest( return { connectedAccountId: connected_account_id, - entityId: account_id, + targetAccountId: account_id, }; } diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts index 01ef9021..916eb315 100644 --- a/lib/composio/connectors/validateGetConnectorsRequest.ts +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -9,7 +9,7 @@ import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; * Validated params for getting connectors. */ export interface GetConnectorsParams { - composioEntityId: string; + accountId: string; } /** @@ -54,9 +54,9 @@ export async function validateGetConnectorsRequest( return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); } - return { composioEntityId: account_id }; + return { accountId: account_id }; } // No account_id: use the authenticated account - return { composioEntityId: accountId }; + return { accountId }; } From 0f7464183edd3b1a13822e73e472328ffaa3efb9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:38:21 -0500 Subject: [PATCH 37/42] refactor: remove unused allowedToolkits filtering from getConnectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No caller uses this option — YAGNI. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/getConnectors.test.ts | 55 +------------------ lib/composio/connectors/getConnectors.ts | 36 +----------- 2 files changed, 4 insertions(+), 87 deletions(-) diff --git a/lib/composio/connectors/__tests__/getConnectors.test.ts b/lib/composio/connectors/__tests__/getConnectors.test.ts index f4cd545c..1ba00ecc 100644 --- a/lib/composio/connectors/__tests__/getConnectors.test.ts +++ b/lib/composio/connectors/__tests__/getConnectors.test.ts @@ -36,7 +36,7 @@ describe("getConnectors", () => { const result = await getConnectors("account-123"); expect(getComposioClient).toHaveBeenCalled(); - expect(mockComposio.create).toHaveBeenCalledWith("account-123", undefined); + expect(mockComposio.create).toHaveBeenCalledWith("account-123"); expect(result).toEqual([ { slug: "googlesheets", @@ -53,26 +53,6 @@ describe("getConnectors", () => { ]); }); - it("should filter by allowed toolkits when provided", async () => { - mockToolkits.mockResolvedValue({ - items: [ - { - slug: "tiktok", - name: "TikTok", - connection: { isActive: true, connectedAccount: { id: "ca_456" } }, - }, - ], - }); - - await getConnectors("artist-456", { - allowedToolkits: ["tiktok"], - }); - - expect(mockComposio.create).toHaveBeenCalledWith("artist-456", { - toolkits: ["tiktok"], - }); - }); - it("should use custom display names when provided", async () => { mockToolkits.mockResolvedValue({ items: [ @@ -91,39 +71,6 @@ describe("getConnectors", () => { expect(result[0].name).toBe("TikTok"); }); - it("should add missing allowed toolkits that are not in Composio response", async () => { - mockToolkits.mockResolvedValue({ - items: [], // Composio returns no toolkits - }); - - const result = await getConnectors("artist-456", { - allowedToolkits: ["tiktok", "instagram"], - displayNames: { tiktok: "TikTok", instagram: "Instagram" }, - }); - - expect(result).toHaveLength(2); - expect(result).toEqual([ - { slug: "tiktok", name: "TikTok", isConnected: false, connectedAccountId: undefined }, - { slug: "instagram", name: "Instagram", isConnected: false, connectedAccountId: undefined }, - ]); - }); - - it("should maintain order of allowed toolkits", async () => { - mockToolkits.mockResolvedValue({ - items: [ - { slug: "instagram", name: "Instagram", connection: null }, - { slug: "tiktok", name: "TikTok", connection: null }, - ], - }); - - const result = await getConnectors("account-123", { - allowedToolkits: ["tiktok", "instagram"], - }); - - expect(result[0].slug).toBe("tiktok"); - expect(result[1].slug).toBe("instagram"); - }); - it("should handle inactive connections", async () => { mockToolkits.mockResolvedValue({ items: [ diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index 1a32eee5..cf01fd99 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -14,11 +14,6 @@ export interface ConnectorInfo { * Options for getting connectors. */ export interface GetConnectorsOptions { - /** - * Filter to only these toolkit slugs. - * If not provided, returns all toolkits. - */ - allowedToolkits?: readonly string[]; /** * Custom display names for toolkits. * e.g., { tiktok: "TikTok" } @@ -39,41 +34,16 @@ export async function getConnectors( accountId: string, options: GetConnectorsOptions = {}, ): Promise { - const { allowedToolkits, displayNames = {} } = options; + const { displayNames = {} } = options; const composio = await getComposioClient(); - // Create session, optionally filtering to allowed toolkits - const sessionOptions = allowedToolkits - ? { toolkits: [...allowedToolkits] as string[] } - : undefined; - - const session = await composio.create(accountId, sessionOptions); + const session = await composio.create(accountId); const toolkits = await session.toolkits(); - // Build connector list - const connectors = toolkits.items.map(toolkit => ({ + return toolkits.items.map(toolkit => ({ slug: toolkit.slug, name: displayNames[toolkit.slug] || toolkit.name, isConnected: toolkit.connection?.isActive ?? false, connectedAccountId: toolkit.connection?.connectedAccount?.id, })); - - // If filtering, ensure we return all allowed toolkits (even if not in Composio response) - if (allowedToolkits) { - const existingSlugs = new Set(connectors.map(c => c.slug)); - for (const slug of allowedToolkits) { - if (!existingSlugs.has(slug)) { - connectors.push({ - slug, - name: displayNames[slug] || slug, - isConnected: false, - connectedAccountId: undefined, - }); - } - } - // Filter to only allowed and maintain order - return allowedToolkits.map(slug => connectors.find(c => c.slug === slug)!); - } - - return connectors; } From f380a4bedaa26b8dd303d6eb79369967fab773db Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:43:58 -0500 Subject: [PATCH 38/42] fix: correct misleading docstring in validateDisconnectConnectorBody The comment claimed ownership verification that the function doesn't do. Updated to reflect it only validates request shape. Co-Authored-By: Claude Opus 4.6 --- .../connectors/validateDisconnectConnectorBody.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index c1b4b013..50635e2f 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -10,13 +10,11 @@ export const disconnectConnectorBodySchema = z.object({ export type DisconnectConnectorBody = z.infer; /** - * Validates request body for DELETE /api/connectors. + * Validates request body shape for DELETE /api/connectors. * - * - User disconnect: { connected_account_id: "ca_xxx" } - * - Entity disconnect: { connected_account_id: "ca_xxx", account_id: "account-uuid" } - * - * When account_id is provided, verifies the connection belongs to that entity. - * When not provided, verifies the connection belongs to the authenticated account. + * Only checks presence and format of fields (connected_account_id required, + * account_id optional UUID). Ownership and authorization checks are performed + * by validateDisconnectConnectorRequest. * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. From a213e312964c8815d20816876a04e2f1427541a8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:47:50 -0500 Subject: [PATCH 39/42] refactor: move checkAccountArtistAccess to lib/artists/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a direct Supabase query — it aggregates supabase calls, so it belongs in the domain layer. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/checkAccountArtistAccess.test.ts | 12 ++++++------ .../checkAccountArtistAccess.ts | 6 +++--- lib/auth/checkAccountAccess.ts | 2 +- .../toolRouter/__tests__/getComposioTools.test.ts | 4 ++-- lib/composio/toolRouter/getTools.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename lib/{supabase/account_artist_ids => artists}/__tests__/checkAccountArtistAccess.test.ts (81%) rename lib/{supabase/account_artist_ids => artists}/checkAccountArtistAccess.ts (79%) diff --git a/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts b/lib/artists/__tests__/checkAccountArtistAccess.test.ts similarity index 81% rename from lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts rename to lib/artists/__tests__/checkAccountArtistAccess.test.ts index c3159287..1bb0b253 100644 --- a/lib/supabase/account_artist_ids/__tests__/checkAccountArtistAccess.test.ts +++ b/lib/artists/__tests__/checkAccountArtistAccess.test.ts @@ -1,21 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; -vi.mock("../selectAccountArtistId", () => ({ +vi.mock("@/lib/supabase/account_artist_ids/selectAccountArtistId", () => ({ selectAccountArtistId: vi.fn(), })); -vi.mock("../../artist_organization_ids/selectArtistOrganizationIds", () => ({ +vi.mock("@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds", () => ({ selectArtistOrganizationIds: vi.fn(), })); -vi.mock("../../account_organization_ids/selectAccountOrganizationIds", () => ({ +vi.mock("@/lib/supabase/account_organization_ids/selectAccountOrganizationIds", () => ({ selectAccountOrganizationIds: vi.fn(), })); -import { selectAccountArtistId } from "../selectAccountArtistId"; -import { selectArtistOrganizationIds } from "../../artist_organization_ids/selectArtistOrganizationIds"; -import { selectAccountOrganizationIds } from "../../account_organization_ids/selectAccountOrganizationIds"; +import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId"; +import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds"; describe("checkAccountArtistAccess", () => { beforeEach(() => { diff --git a/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts b/lib/artists/checkAccountArtistAccess.ts similarity index 79% rename from lib/supabase/account_artist_ids/checkAccountArtistAccess.ts rename to lib/artists/checkAccountArtistAccess.ts index f1424456..a0b8defe 100644 --- a/lib/supabase/account_artist_ids/checkAccountArtistAccess.ts +++ b/lib/artists/checkAccountArtistAccess.ts @@ -1,6 +1,6 @@ -import { selectAccountArtistId } from "./selectAccountArtistId"; -import { selectArtistOrganizationIds } from "../artist_organization_ids/selectArtistOrganizationIds"; -import { selectAccountOrganizationIds } from "../account_organization_ids/selectAccountOrganizationIds"; +import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId"; +import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds"; /** * Check if an account has access to a specific artist. diff --git a/lib/auth/checkAccountAccess.ts b/lib/auth/checkAccountAccess.ts index 841b7d19..68fd3db2 100644 --- a/lib/auth/checkAccountAccess.ts +++ b/lib/auth/checkAccountAccess.ts @@ -1,4 +1,4 @@ -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; import { checkAccountWorkspaceAccess } from "@/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts index 34789aac..594717e5 100644 --- a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -3,7 +3,7 @@ import { getComposioTools } from "../getTools"; import { createToolRouterSession } from "../createToolRouterSession"; import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; // Mock dependencies vi.mock("../createToolRouterSession", () => ({ @@ -14,7 +14,7 @@ vi.mock("../getArtistConnectionsFromComposio", () => ({ getArtistConnectionsFromComposio: vi.fn(), })); -vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({ +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ checkAccountArtistAccess: vi.fn(), })); diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 1e63953f..697e4d03 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,6 +1,6 @@ import { createToolRouterSession } from "./createToolRouterSession"; import { getArtistConnectionsFromComposio } from "./getArtistConnectionsFromComposio"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; import type { Tool, ToolSet } from "ai"; /** From bbd541560b4d95dda7cfb0a978ee2c4b979ede54 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:51:11 -0500 Subject: [PATCH 40/42] refactor: delete checkAccountWorkspaceAccess wrapper, use selectAccountWorkspaceId directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAGNI — the wrapper was just !!data. The sole consumer now calls the supabase query directly. Co-Authored-By: Claude Opus 4.6 --- lib/auth/checkAccountAccess.ts | 4 ++-- .../checkAccountWorkspaceAccess.ts | 21 ------------------- 2 files changed, 2 insertions(+), 23 deletions(-) delete mode 100644 lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts diff --git a/lib/auth/checkAccountAccess.ts b/lib/auth/checkAccountAccess.ts index 68fd3db2..4ebda208 100644 --- a/lib/auth/checkAccountAccess.ts +++ b/lib/auth/checkAccountAccess.ts @@ -1,5 +1,5 @@ import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; -import { checkAccountWorkspaceAccess } from "@/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess"; +import { selectAccountWorkspaceId } from "@/lib/supabase/account_workspace_ids/selectAccountWorkspaceId"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; /** @@ -53,7 +53,7 @@ export async function checkAccountAccess( } // 3. Workspace access — target is a workspace the caller owns - const isWorkspace = await checkAccountWorkspaceAccess(authenticatedAccountId, targetAccountId); + const isWorkspace = await selectAccountWorkspaceId(authenticatedAccountId, targetAccountId); if (isWorkspace) { return { hasAccess: true, entityType: "workspace" }; } diff --git a/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts b/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts deleted file mode 100644 index a766a9a6..00000000 --- a/lib/supabase/account_workspace_ids/checkAccountWorkspaceAccess.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { selectAccountWorkspaceId } from "./selectAccountWorkspaceId"; - -/** - * Check if an account has access to a specific workspace. - * - * Access is granted if: - * 1. Account has direct ownership via account_workspace_ids - * - * Fails closed: returns false on any database error to deny access safely. - * - * @param accountId - The account ID to check - * @param workspaceId - The workspace ID to check access for - * @returns true if the account has access to the workspace, false otherwise - */ -export async function checkAccountWorkspaceAccess( - accountId: string, - workspaceId: string, -): Promise { - const data = await selectAccountWorkspaceId(accountId, workspaceId); - return !!data; -} From f8e8bbc366874b66f5f911858777e106e4c167e6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 11 Feb 2026 22:59:21 -0500 Subject: [PATCH 41/42] fix: filter artist connections locally after allowedToolkits removal Co-Authored-By: Claude Opus 4.6 --- .../__tests__/getArtistConnectionsFromComposio.test.ts | 8 +++----- .../toolRouter/getArtistConnectionsFromComposio.ts | 10 ++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts index b22d44dc..fc8d109d 100644 --- a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts +++ b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts @@ -19,9 +19,7 @@ describe("getArtistConnectionsFromComposio", () => { const result = await getArtistConnectionsFromComposio("artist-123"); - expect(getConnectors).toHaveBeenCalledWith("artist-123", { - allowedToolkits: ["tiktok"], - }); + expect(getConnectors).toHaveBeenCalledWith("artist-123"); expect(result).toEqual({}); }); @@ -51,7 +49,7 @@ describe("getArtistConnectionsFromComposio", () => { }); }); - it("should handle multiple connected accounts", async () => { + it("should only include allowed artist connectors", async () => { vi.mocked(getConnectors).mockResolvedValue([ { slug: "tiktok", connectedAccountId: "tiktok-account-1" }, { slug: "instagram", connectedAccountId: "instagram-account-2" }, @@ -61,8 +59,8 @@ describe("getArtistConnectionsFromComposio", () => { expect(result).toEqual({ tiktok: "tiktok-account-1", - instagram: "instagram-account-2", }); + expect(result).not.toHaveProperty("instagram"); }); it("should return empty object when getConnectors returns empty array", async () => { diff --git a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts index 060321c8..2730403d 100644 --- a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts +++ b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts @@ -12,15 +12,13 @@ import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; export async function getArtistConnectionsFromComposio( artistId: string, ): Promise> { - // Use unified getConnectors with artist filter - const connectors = await getConnectors(artistId, { - allowedToolkits: ALLOWED_ARTIST_CONNECTORS, - }); + const connectors = await getConnectors(artistId); - // Build connections map from connected connectors + // Build connections map, filtered to allowed artist connectors + const allowed = new Set(ALLOWED_ARTIST_CONNECTORS); const connections: Record = {}; for (const connector of connectors) { - if (connector.connectedAccountId) { + if (allowed.has(connector.slug) && connector.connectedAccountId) { connections[connector.slug] = connector.connectedAccountId; } } From acf019d17c923a1b09a62983fcbbd5eda561c549 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Feb 2026 09:54:41 -0500 Subject: [PATCH 42/42] fix: add missing_fields to connector validation error responses Adds custom Zod messages and missing_fields/status fields to error responses for both POST and DELETE /api/connectors validation, matching the standard pattern used across other API endpoints. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/validateAuthorizeConnectorBody.test.ts | 5 ++++- .../__tests__/validateDisconnectConnectorBody.test.ts | 5 ++++- lib/composio/connectors/validateAuthorizeConnectorBody.ts | 2 ++ lib/composio/connectors/validateDisconnectConnectorBody.ts | 6 +++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts index 7968b45b..593cbc69 100644 --- a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -44,12 +44,15 @@ describe("validateAuthorizeConnectorBody", () => { }); }); - it("should return 400 when connector is missing", () => { + it("should return 400 with field name when connector is missing", async () => { const result = validateAuthorizeConnectorBody({}); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(400); + const body = await response.json(); + expect(body.missing_fields).toEqual(["connector"]); + expect(body.error).toBe("connector is required"); }); it("should return 400 when connector is empty", () => { diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts index 002c6d1e..5f6f2382 100644 --- a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts @@ -31,12 +31,15 @@ describe("validateDisconnectConnectorBody", () => { }); }); - it("should return 400 when connected_account_id is missing", () => { + it("should return 400 with field name when connected_account_id is missing", async () => { const result = validateDisconnectConnectorBody({}); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(400); + const body = await response.json(); + expect(body.missing_fields).toEqual(["connected_account_id"]); + expect(body.error).toBe("connected_account_id is required"); }); it("should return 400 when connected_account_id is empty", () => { diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index 415917c4..64ab16f0 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -31,6 +31,8 @@ export function validateAuthorizeConnectorBody( const firstError = result.error.issues[0]; return NextResponse.json( { + status: "error", + missing_fields: firstError.path, error: firstError.message, }, { diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index 50635e2f..52edafd8 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -3,7 +3,9 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; export const disconnectConnectorBodySchema = z.object({ - connected_account_id: z.string().min(1, "connected_account_id is required"), + connected_account_id: z + .string({ message: "connected_account_id is required" }) + .min(1, "connected_account_id cannot be empty"), account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); @@ -28,6 +30,8 @@ export function validateDisconnectConnectorBody( const firstError = result.error.issues[0]; return NextResponse.json( { + status: "error", + missing_fields: firstError.path, error: firstError.message, }, {