From ae33d599859ace33c2026cba3acf7e3c4a5ecad0 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Mon, 6 Apr 2026 23:35:44 +0000 Subject: [PATCH] feat: enrich connectors response with social profile data GET /api/connectors now returns avatar and username for connected Instagram/TikTok connectors by looking up matching social profiles from the artist's account_socials in Supabase. - Add avatar/username to ConnectorInfo interface - New enrichConnectorsWithSocials function with hostname-based matching - Handler calls enrichment after fetching Composio connectors - Tests for enrichment logic Co-Authored-By: Paperclip --- .../enrichConnectorsWithSocials.test.ts | 184 ++++++++++++++++++ .../connectors/enrichConnectorsWithSocials.ts | 69 +++++++ lib/composio/connectors/getConnectors.ts | 12 +- .../connectors/getConnectorsHandler.ts | 15 +- 4 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 lib/composio/connectors/__tests__/enrichConnectorsWithSocials.test.ts create mode 100644 lib/composio/connectors/enrichConnectorsWithSocials.ts diff --git a/lib/composio/connectors/__tests__/enrichConnectorsWithSocials.test.ts b/lib/composio/connectors/__tests__/enrichConnectorsWithSocials.test.ts new file mode 100644 index 00000000..7e4666e4 --- /dev/null +++ b/lib/composio/connectors/__tests__/enrichConnectorsWithSocials.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { enrichConnectorsWithSocials } from "../enrichConnectorsWithSocials"; +import type { ConnectorInfo } from "../getConnectors"; +import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; + +vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({ + selectAccountSocials: vi.fn(), +})); + +describe("enrichConnectorsWithSocials", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should add avatar and username from matching social profiles", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "instagram", name: "Instagram", isConnected: true, connectedAccountId: "ca_1" }, + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "ca_2" }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue([ + { + id: "as_1", + account_id: "artist-123", + social_id: "s_1", + social: { + id: "s_1", + username: "artist_ig", + profile_url: "https://www.instagram.com/artist_ig", + avatar: "https://cdn.example.com/ig-avatar.jpg", + bio: null, + followerCount: 1000, + followingCount: 50, + region: null, + updated_at: "2026-01-01T00:00:00Z", + }, + }, + { + id: "as_2", + account_id: "artist-123", + social_id: "s_2", + social: { + id: "s_2", + username: "artist_tt", + profile_url: "https://tiktok.com/@artist_tt", + avatar: "https://cdn.example.com/tt-avatar.jpg", + bio: null, + followerCount: 5000, + followingCount: 100, + region: null, + updated_at: "2026-01-01T00:00:00Z", + }, + }, + ] as any); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + expect(result[0]).toMatchObject({ + slug: "instagram", + avatar: "https://cdn.example.com/ig-avatar.jpg", + username: "artist_ig", + }); + expect(result[1]).toMatchObject({ + slug: "tiktok", + avatar: "https://cdn.example.com/tt-avatar.jpg", + username: "artist_tt", + }); + }); + + it("should not add social data for unconnected connectors", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "instagram", name: "Instagram", isConnected: false }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue([ + { + id: "as_1", + account_id: "artist-123", + social_id: "s_1", + social: { + id: "s_1", + username: "artist_ig", + profile_url: "https://www.instagram.com/artist_ig", + avatar: "https://cdn.example.com/ig-avatar.jpg", + bio: null, + followerCount: 1000, + followingCount: 50, + region: null, + updated_at: "2026-01-01T00:00:00Z", + }, + }, + ] as any); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + expect(result[0].avatar).toBeUndefined(); + expect(result[0].username).toBeUndefined(); + }); + + it("should handle no matching social profiles", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "instagram", name: "Instagram", isConnected: true, connectedAccountId: "ca_1" }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue([ + { + id: "as_1", + account_id: "artist-123", + social_id: "s_1", + social: { + id: "s_1", + username: "artist_tt", + profile_url: "https://tiktok.com/@artist_tt", + avatar: "https://cdn.example.com/tt-avatar.jpg", + bio: null, + followerCount: 1000, + followingCount: 50, + region: null, + updated_at: "2026-01-01T00:00:00Z", + }, + }, + ] as any); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + expect(result[0].avatar).toBeUndefined(); + expect(result[0].username).toBeUndefined(); + }); + + it("should handle null socials result", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "instagram", name: "Instagram", isConnected: true, connectedAccountId: "ca_1" }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue(null); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + expect(result[0].avatar).toBeUndefined(); + }); + + it("should match by hostname and ignore subdomains", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "instagram", name: "Instagram", isConnected: true, connectedAccountId: "ca_1" }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue([ + { + id: "as_1", + account_id: "artist-123", + social_id: "s_1", + social: { + id: "s_1", + username: "artist_ig", + profile_url: "https://scontent.cdninstagram.com/something", + avatar: "https://cdn.example.com/wrong.jpg", + bio: null, + followerCount: 1000, + followingCount: 50, + region: null, + updated_at: "2026-01-01T00:00:00Z", + }, + }, + ] as any); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + // cdninstagram.com is NOT instagram.com — should not match + expect(result[0].avatar).toBeUndefined(); + }); + + it("should not enrich non-social connectors like googlesheets", async () => { + const connectors: ConnectorInfo[] = [ + { slug: "googlesheets", name: "Google Sheets", isConnected: true, connectedAccountId: "ca_1" }, + ]; + + vi.mocked(selectAccountSocials).mockResolvedValue([]); + + const result = await enrichConnectorsWithSocials(connectors, "artist-123"); + + expect(selectAccountSocials).toHaveBeenCalled(); + expect(result[0].avatar).toBeUndefined(); + }); +}); diff --git a/lib/composio/connectors/enrichConnectorsWithSocials.ts b/lib/composio/connectors/enrichConnectorsWithSocials.ts new file mode 100644 index 00000000..88135efc --- /dev/null +++ b/lib/composio/connectors/enrichConnectorsWithSocials.ts @@ -0,0 +1,69 @@ +import type { ConnectorInfo } from "./getConnectors"; +import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; +import type { Tables } from "@/types/database.types"; + +type SocialRow = Tables<"socials">; + +/** + * Connector slug → expected social profile hostname. + */ +const CONNECTOR_SOCIAL_HOSTNAMES: Record = { + instagram: "instagram.com", + tiktok: "tiktok.com", +}; + +/** + * Match a social profile_url hostname to a connector slug. + */ +function matchesPlatform( + profileUrl: string, + expectedHostname: string, +): boolean { + try { + const hostname = new URL(profileUrl).hostname.toLowerCase(); + return ( + hostname === expectedHostname || hostname.endsWith(`.${expectedHostname}`) + ); + } catch { + return false; + } +} + +/** + * Enrich connectors with social profile data (avatar, username) from Supabase. + * + * For each connected connector that has a social platform mapping, + * looks up the artist's social profiles and adds avatar/username. + * + * @param connectors - The connectors from Composio + * @param accountId - The artist account ID to look up socials for + * @returns Enriched connectors with optional avatar and username + */ +export async function enrichConnectorsWithSocials( + connectors: ConnectorInfo[], + accountId: string, +): Promise { + const accountSocials = await selectAccountSocials(accountId); + const socials: SocialRow[] = (accountSocials || []) + .map((item) => (item as any).social as SocialRow | null) + .filter((s): s is SocialRow => s !== null); + + return connectors.map((connector) => { + if (!connector.isConnected) return connector; + + const expectedHostname = CONNECTOR_SOCIAL_HOSTNAMES[connector.slug]; + if (!expectedHostname) return connector; + + const social = socials.find((s) => + matchesPlatform(s.profile_url, expectedHostname), + ); + + if (!social) return connector; + + return { + ...connector, + avatar: social.avatar, + username: social.username, + }; + }); +} diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index 243132bf..7fe3d54c 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -9,6 +9,8 @@ export interface ConnectorInfo { name: string; isConnected: boolean; connectedAccountId?: string; + avatar?: string | null; + username?: string | null; } /** @@ -16,7 +18,13 @@ export interface ConnectorInfo { * Passed explicitly to composio.create() because session.toolkits() * only returns the first 20 by default. */ -const SUPPORTED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok", "instagram"]; +const SUPPORTED_TOOLKITS = [ + "googlesheets", + "googledrive", + "googledocs", + "tiktok", + "instagram", +]; /** * Options for getting connectors. @@ -52,7 +60,7 @@ export async function getConnectors( }); const toolkits = await session.toolkits(); - return toolkits.items.map(toolkit => { + return toolkits.items.map((toolkit) => { const slug = toolkit.slug.toLowerCase(); return { slug, diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts index d92f1293..31ddeabb 100644 --- a/lib/composio/connectors/getConnectorsHandler.ts +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetConnectorsRequest } from "./validateGetConnectorsRequest"; import { getConnectors } from "./getConnectors"; +import { enrichConnectorsWithSocials } from "./enrichConnectorsWithSocials"; /** * Display names for connectors. @@ -19,11 +20,15 @@ const CONNECTOR_DISPLAY_NAMES: Record = { * * Lists all available connectors and their connection status. * Use account_id query param to get connectors for a specific entity. + * When an account_id is provided, connectors are enriched with social + * profile data (avatar, username) from the artist's linked socials. * * @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 { @@ -40,15 +45,19 @@ export async function getConnectorsHandler(request: NextRequest): Promise