From 69b3e3bfff3fa0b09ff5ab6f421d10d7bb885aad Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 14 Apr 2026 20:46:51 +0000 Subject: [PATCH] feat: default to no artist, require artist name in Slack prompt (REC-64) Remove hardcoded Gatsby Grace artist default from the content agent Slack bot. The bot now requires an artist name in the prompt (e.g. "generate a video for Mac Miller") and looks up the artist by name within the organization. - Add artistName field to contentPromptFlagsSchema for AI extraction - Add selectAccountByNameInOrg for case-insensitive artist name lookup - Update registerOnNewMention to resolve artist from prompt instead of hardcoding - If no artist specified, prompt user to include one - If artist not found, inform user with error message - 38 tests passing (20 registerOnNewMention, 13 parseContentPrompt, 5 selectAccountByNameInOrg) Co-Authored-By: Paperclip --- .../__tests__/parseContentPrompt.test.ts | 31 +++ .../__tests__/registerOnNewMention.test.ts | 229 ++++++++++-------- .../content/createContentPromptAgent.ts | 6 + .../content/handlers/registerOnNewMention.ts | 29 ++- .../selectAccountByNameInOrg.test.ts | 68 ++++++ .../accounts/selectAccountByNameInOrg.ts | 31 +++ 6 files changed, 293 insertions(+), 101 deletions(-) create mode 100644 lib/supabase/accounts/__tests__/selectAccountByNameInOrg.test.ts create mode 100644 lib/supabase/accounts/selectAccountByNameInOrg.ts diff --git a/lib/agents/content/__tests__/parseContentPrompt.test.ts b/lib/agents/content/__tests__/parseContentPrompt.test.ts index b2bb643e..e3ff4ee2 100644 --- a/lib/agents/content/__tests__/parseContentPrompt.test.ts +++ b/lib/agents/content/__tests__/parseContentPrompt.test.ts @@ -174,6 +174,37 @@ describe("parseContentPrompt", () => { expect(result.songs).toEqual(["hiccups"]); }); + it("extracts artistName from prompt when specified", async () => { + const flags: ContentPromptFlags = { + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Mac Miller", + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("make a video for Mac Miller"); + + expect(result.artistName).toBe("Mac Miller"); + }); + + it("returns undefined artistName when no artist mentioned", async () => { + const flags: ContentPromptFlags = { + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("make me a video"); + + expect(result.artistName).toBeUndefined(); + }); + it("returns undefined songs when no songs mentioned", async () => { const flags: ContentPromptFlags = { lipsync: false, diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index 7e50cfc7..622bb9fd 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -21,6 +21,10 @@ vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ selectAccountSnapshots: vi.fn(), })); +vi.mock("@/lib/supabase/accounts/selectAccountByNameInOrg", () => ({ + selectAccountByNameInOrg: vi.fn(), +})); + vi.mock("@/lib/content/contentTemplates", () => ({ DEFAULT_CONTENT_TEMPLATE: "artist-caption-bedroom", })); @@ -34,7 +38,7 @@ vi.mock("../extractMessageAttachments", () => ({ })); vi.mock("@/lib/agents/buildTaskCard", () => ({ - buildTaskCard: vi.fn((_title: string, _message: string, _runId: string) => ({ + buildTaskCard: vi.fn((_: string, __: string, ___: string) => ({ mockCard: true, })), })); @@ -46,6 +50,9 @@ const { resolveArtistSlug } = await import("@/lib/content/resolveArtistSlug"); const { getArtistContentReadiness } = await import("@/lib/content/getArtistContentReadiness"); const { parseContentPrompt } = await import("../parseContentPrompt"); const { extractMessageAttachments } = await import("../extractMessageAttachments"); +const { selectAccountByNameInOrg } = await import( + "@/lib/supabase/accounts/selectAccountByNameInOrg" +); /** * Creates a mock content agent bot for testing. @@ -85,6 +92,22 @@ function createMockMessage(text: string) { return { text }; } +/** + * Sets up common mocks for tests that expect successful content creation. + * Includes artistName in parsed flags and mocks artist lookup. + */ +function setupSuccessfulArtistLookup() { + vi.mocked(selectAccountByNameInOrg).mockResolvedValue([ + { id: "artist-account-1", name: "Test Artist" }, + ]); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); +} + describe("registerOnNewMention", () => { beforeEach(() => { vi.clearAllMocks(); @@ -110,13 +133,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make me a lipsync video"); @@ -135,13 +154,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make me a lipsync video"); @@ -160,13 +175,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make me 3 videos"); @@ -185,13 +196,9 @@ describe("registerOnNewMention", () => { captionLength: "long", upscale: true, template: "artist-caption-stage", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("high quality stage video with long caption"); @@ -217,13 +224,9 @@ describe("registerOnNewMention", () => { upscale: false, template: "artist-caption-bedroom", songs: ["hiccups"], + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make a lipsync video for the hiccups song"); @@ -244,13 +247,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make me a video"); @@ -270,13 +269,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make 2 lipsync videos"); @@ -299,13 +294,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -325,17 +316,13 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: "https://blob.vercel-storage.com/song.mp3", imageUrls: [], }); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -355,17 +342,13 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: null, imageUrls: ["https://blob.vercel-storage.com/face.png"], }); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -388,11 +371,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-release-editorial", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: null, imageUrls: [ @@ -401,8 +382,6 @@ describe("registerOnNewMention", () => { "https://blob.vercel-storage.com/cover2.png", ], }); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make an editorial video"); @@ -429,13 +408,9 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -455,17 +430,13 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(extractMessageAttachments).mockResolvedValue({ songUrl: "https://blob.vercel-storage.com/song.mp3", imageUrls: ["https://blob.vercel-storage.com/face.png"], }); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -487,13 +458,9 @@ describe("registerOnNewMention", () => { upscale: false, template: "artist-caption-bedroom", songs: ["hiccups"], + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); - vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + setupSuccessfulArtistLookup(); const thread = createMockThread(); const message = createMockMessage("make a video for hiccups"); @@ -514,13 +481,10 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-abc-123" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make a video"); @@ -531,7 +495,6 @@ describe("registerOnNewMention", () => { expect.stringContaining("test-artist"), "run-abc-123", ); - // Second post call is the card expect(thread.post).toHaveBeenCalledWith({ card: { mockCard: true } }); }); @@ -545,16 +508,13 @@ describe("registerOnNewMention", () => { captionLength: "short", upscale: false, template: "artist-caption-bedroom", + artistName: "Test Artist", }); - vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); - vi.mocked(getArtistContentReadiness).mockResolvedValue({ - githubRepo: "https://github.com/test/repo", - } as never); + setupSuccessfulArtistLookup(); vi.mocked(triggerCreateContent) .mockResolvedValueOnce({ id: "run-first" }) .mockResolvedValueOnce({ id: "run-second" }) .mockResolvedValueOnce({ id: "run-third" }); - vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); const thread = createMockThread(); const message = createMockMessage("make 3 videos"); @@ -566,4 +526,81 @@ describe("registerOnNewMention", () => { "run-first", ); }); + + it("prompts user to specify an artist when no artistName is provided", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + + const thread = createMockThread(); + const message = createMockMessage("make a video"); + await bot.getHandler()!(thread, message); + + expect(thread.post).toHaveBeenCalledTimes(1); + expect(thread.post).toHaveBeenCalledWith(expect.stringContaining("specify an artist")); + expect(triggerCreateContent).not.toHaveBeenCalled(); + }); + + it("posts error when artist name is not found in organization", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Unknown Artist", + }); + vi.mocked(selectAccountByNameInOrg).mockResolvedValue([]); + + const thread = createMockThread(); + const message = createMockMessage("make a video for Unknown Artist"); + await bot.getHandler()!(thread, message); + + expect(thread.post).toHaveBeenCalledTimes(1); + expect(thread.post).toHaveBeenCalledWith(expect.stringContaining("Unknown Artist")); + expect(triggerCreateContent).not.toHaveBeenCalled(); + }); + + it("looks up artist by name using selectAccountByNameInOrg", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Mac Miller", + }); + vi.mocked(selectAccountByNameInOrg).mockResolvedValue([ + { id: "mac-account-id", name: "Mac Miller" }, + ]); + vi.mocked(resolveArtistSlug).mockResolvedValue("mac-miller"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video for Mac Miller"); + await bot.getHandler()!(thread, message); + + expect(selectAccountByNameInOrg).toHaveBeenCalledWith(expect.any(String), "Mac Miller"); + expect(resolveArtistSlug).toHaveBeenCalledWith("mac-account-id"); + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ artistSlug: "mac-miller" }), + ); + }); }); diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index 93dc5d10..b47bfd65 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -35,6 +35,12 @@ export const contentPromptFlagsSchema = z.object({ songs: songsSchema.describe( "Song names or slugs mentioned in the prompt. Extract from phrases like 'the hiccups song', 'use track X', 'for song Y'. Omit if no specific songs are mentioned.", ), + artistName: z + .string() + .optional() + .describe( + "The artist name mentioned in the prompt. Extract from phrases like 'for Mac Miller', 'generate a video for Gatsby Grace', 'make content for [artist]'. Omit if no artist name is mentioned.", + ), }); export type ContentPromptFlags = z.infer; diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index e02bb6ea..c8c98bfe 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -4,10 +4,13 @@ import { triggerPollContentRun } from "@/lib/trigger/triggerPollContentRun"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { selectAccountByNameInOrg } from "@/lib/supabase/accounts/selectAccountByNameInOrg"; import { parseContentPrompt } from "../parseContentPrompt"; import { extractMessageAttachments } from "../extractMessageAttachments"; import { buildTaskCard } from "@/lib/agents/buildTaskCard"; +const ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; + /** * Registers the onNewMention handler on the content agent bot. * Parses the mention text with AI to extract content creation flags, @@ -19,17 +22,33 @@ import { buildTaskCard } from "@/lib/agents/buildTaskCard"; export function registerOnNewMention(bot: ContentAgentBot) { bot.onNewMention(async (thread, message) => { try { - const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; - const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; + const accountId = ACCOUNT_ID; // Parse the user's natural-language prompt into structured flags - const { lipsync, batch, captionLength, upscale, template, songs } = await parseContentPrompt( - message.text, - ); + const { lipsync, batch, captionLength, upscale, template, songs, artistName } = + await parseContentPrompt(message.text); // Extract audio/image attachments from the Slack message const { songUrl, imageUrls } = await extractMessageAttachments(message); + // Resolve artist from name — no default artist + if (!artistName) { + await thread.post( + 'Please specify an artist name in your prompt (e.g. "generate a video for Mac Miller").', + ); + return; + } + + const matchedAccounts = await selectAccountByNameInOrg(accountId, artistName); + if (matchedAccounts.length === 0) { + await thread.post( + `No artist found matching \`${artistName}\`. Please check the name and try again.`, + ); + return; + } + + const artistAccountId = matchedAccounts[0].id; + // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); if (!artistSlug) { diff --git a/lib/supabase/accounts/__tests__/selectAccountByNameInOrg.test.ts b/lib/supabase/accounts/__tests__/selectAccountByNameInOrg.test.ts new file mode 100644 index 00000000..5546d59f --- /dev/null +++ b/lib/supabase/accounts/__tests__/selectAccountByNameInOrg.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockIlike = vi.fn(); +const mockEq = vi.fn(() => ({ ilike: mockIlike })); +const mockSelect = vi.fn(() => ({ eq: mockEq })); +const mockFrom = vi.fn(() => ({ select: mockSelect })); + +vi.mock("../../serverClient", () => ({ + default: { from: (...args: unknown[]) => mockFrom(...args) }, +})); + +const { selectAccountByNameInOrg } = await import("../selectAccountByNameInOrg"); + +beforeEach(() => { + vi.clearAllMocks(); + mockIlike.mockResolvedValue({ data: [], error: null }); +}); + +describe("selectAccountByNameInOrg", () => { + it("queries account_organization_ids with organization_id and joins accounts", async () => { + await selectAccountByNameInOrg("org-1", "Mac Miller"); + + expect(mockFrom).toHaveBeenCalledWith("account_organization_ids"); + expect(mockSelect).toHaveBeenCalledWith( + "account:accounts!account_organization_ids_account_id_fkey ( id, name )", + ); + expect(mockEq).toHaveBeenCalledWith("organization_id", "org-1"); + expect(mockIlike).toHaveBeenCalledWith("account.name", "%Mac Miller%"); + }); + + it("returns matching accounts", async () => { + mockIlike.mockResolvedValue({ + data: [{ account: { id: "artist-1", name: "Mac Miller" } }], + error: null, + }); + + const result = await selectAccountByNameInOrg("org-1", "Mac Miller"); + + expect(result).toEqual([{ id: "artist-1", name: "Mac Miller" }]); + }); + + it("returns empty array when no matches found", async () => { + mockIlike.mockResolvedValue({ data: [], error: null }); + + const result = await selectAccountByNameInOrg("org-1", "Unknown Artist"); + + expect(result).toEqual([]); + }); + + it("returns empty array on database error", async () => { + mockIlike.mockResolvedValue({ data: null, error: { message: "DB error" } }); + + const result = await selectAccountByNameInOrg("org-1", "Mac Miller"); + + expect(result).toEqual([]); + }); + + it("filters out rows where account is null", async () => { + mockIlike.mockResolvedValue({ + data: [{ account: { id: "artist-1", name: "Mac Miller" } }, { account: null }], + error: null, + }); + + const result = await selectAccountByNameInOrg("org-1", "Mac"); + + expect(result).toEqual([{ id: "artist-1", name: "Mac Miller" }]); + }); +}); diff --git a/lib/supabase/accounts/selectAccountByNameInOrg.ts b/lib/supabase/accounts/selectAccountByNameInOrg.ts new file mode 100644 index 00000000..7aa02f12 --- /dev/null +++ b/lib/supabase/accounts/selectAccountByNameInOrg.ts @@ -0,0 +1,31 @@ +import supabase from "../serverClient"; + +interface AccountMatch { + id: string; + name: string; +} + +/** + * Finds accounts by name within an organization using case-insensitive partial matching. + * + * @param organizationId - The organization to search within + * @param name - The artist name to search for + * @returns Matching accounts (id and name), or empty array if none found + */ +export async function selectAccountByNameInOrg( + organizationId: string, + name: string, +): Promise { + const { data, error } = await supabase + .from("account_organization_ids") + .select("account:accounts!account_organization_ids_account_id_fkey ( id, name )") + .eq("organization_id", organizationId) + .ilike("account.name", `%${name}%`); + + if (error || !data) { + return []; + } + + const rows = data as unknown as { account: AccountMatch | null }[]; + return rows.filter(row => row.account !== null).map(row => row.account as AccountMatch); +}