Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions lib/composio/connectors/__tests__/enrichConnectorsWithSocials.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
69 changes: 69 additions & 0 deletions lib/composio/connectors/enrichConnectorsWithSocials.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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();
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: new URL(profileUrl) fails for normalized profile URLs stored without protocol, causing social matching to miss valid Instagram/TikTok records.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/composio/connectors/enrichConnectorsWithSocials.ts, line 23:

<comment>`new URL(profileUrl)` fails for normalized profile URLs stored without protocol, causing social matching to miss valid Instagram/TikTok records.</comment>

<file context>
@@ -0,0 +1,69 @@
+  expectedHostname: string,
+): boolean {
+  try {
+    const hostname = new URL(profileUrl).hostname.toLowerCase();
+    return (
+      hostname === expectedHostname || hostname.endsWith(`.${expectedHostname}`)
</file context>
Fix with Cubic

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<ConnectorInfo[]> {
const accountSocials = await selectAccountSocials(accountId);
const socials: SocialRow[] = (accountSocials || [])
.map((item) => (item as any).social as SocialRow | null)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Enforce Clear Code Style and Maintainability Practices

Rule 1 (strict TypeScript typing) is violated by using any in the social mapping; use the typed social field directly instead of bypassing type checks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/composio/connectors/enrichConnectorsWithSocials.ts, line 48:

<comment>Rule 1 (strict TypeScript typing) is violated by using `any` in the social mapping; use the typed `social` field directly instead of bypassing type checks.</comment>

<file context>
@@ -0,0 +1,69 @@
+): Promise<ConnectorInfo[]> {
+  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);
+
</file context>
Fix with Cubic

.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,
};
});
}
12 changes: 10 additions & 2 deletions lib/composio/connectors/getConnectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ export interface ConnectorInfo {
name: string;
isConnected: boolean;
connectedAccountId?: string;
avatar?: string | null;
username?: string | null;
}

/**
* All toolkit slugs the platform supports.
* 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.
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 12 additions & 3 deletions lib/composio/connectors/getConnectorsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,11 +20,15 @@ const CONNECTOR_DISPLAY_NAMES: Record<string, string> = {
*
* 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<NextResponse> {
export async function getConnectorsHandler(
request: NextRequest,
): Promise<NextResponse> {
const headers = getCorsHeaders();

try {
Expand All @@ -40,15 +45,19 @@ export async function getConnectorsHandler(request: NextRequest): Promise<NextRe
displayNames: CONNECTOR_DISPLAY_NAMES,
});

// Enrich with social profile data (avatar, username)
const enriched = await enrichConnectorsWithSocials(connectors, accountId);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle enrichment failures gracefully so social-profile lookup errors don’t turn /api/connectors into a 500 when connector data was fetched successfully.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/composio/connectors/getConnectorsHandler.ts, line 49:

<comment>Handle enrichment failures gracefully so social-profile lookup errors don’t turn `/api/connectors` into a 500 when connector data was fetched successfully.</comment>

<file context>
@@ -40,15 +45,19 @@ export async function getConnectorsHandler(request: NextRequest): Promise<NextRe
     });
 
+    // Enrich with social profile data (avatar, username)
+    const enriched = await enrichConnectorsWithSocials(connectors, accountId);
+
     return NextResponse.json(
</file context>
Suggested change
const enriched = await enrichConnectorsWithSocials(connectors, accountId);
const enriched = await enrichConnectorsWithSocials(connectors, accountId).catch(
() => connectors,
);
Fix with Cubic


return NextResponse.json(
{
success: true,
connectors,
connectors: enriched,
},
{ 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 });
}
}
Loading