diff --git a/app/api/accounts/[id]/route.ts b/app/api/accounts/[id]/route.ts index bf1b034..42b388b 100644 --- a/app/api/accounts/[id]/route.ts +++ b/app/api/accounts/[id]/route.ts @@ -23,13 +23,10 @@ export async function OPTIONS() { * - id (required): The unique identifier of the account (UUID) * * @param request - The request object + * @param params.params * @param params - Route params containing the account ID * @returns A NextResponse with account data */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { return getAccountHandler(request, params); } - diff --git a/app/api/artists/route.ts b/app/api/artists/route.ts index 64b65a4..7fe9a21 100644 --- a/app/api/artists/route.ts +++ b/app/api/artists/route.ts @@ -54,4 +54,3 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return createArtistPostHandler(request); } - diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index 5bf54d6..c8945a6 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -27,6 +27,7 @@ export async function OPTIONS() { * - connector: The connector slug, e.g., "googlesheets" (required) * - callback_url: Optional custom callback URL after OAuth * + * @param request * @returns The redirect URL for OAuth authorization */ export async function POST(request: NextRequest): Promise { @@ -60,8 +61,7 @@ export async function POST(request: NextRequest): Promise { { status: 200, headers }, ); } catch (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/app/api/connectors/route.ts b/app/api/connectors/route.ts index 8dfdab9..adf5b20 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -24,6 +24,7 @@ export async function OPTIONS() { * * Authentication: x-api-key header required. * + * @param request * @returns List of connectors with connection status */ export async function GET(request: NextRequest): Promise { @@ -49,8 +50,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 }); } } @@ -63,6 +63,8 @@ export async function GET(request: NextRequest): Promise { * Authentication: x-api-key header required. * * Body: { connected_account_id: string } + * + * @param request */ export async function DELETE(request: NextRequest): Promise { const headers = getCorsHeaders(); @@ -88,7 +90,7 @@ export async function DELETE(request: NextRequest): Promise { if (!isOwner) { return NextResponse.json( { error: "Connected account not found or does not belong to this user" }, - { status: 403, headers } + { status: 403, headers }, ); } @@ -102,8 +104,7 @@ export async function DELETE(request: NextRequest): Promise { { 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/app/api/organizations/artists/route.ts b/app/api/organizations/artists/route.ts index c722e06..028c9f5 100644 --- a/app/api/organizations/artists/route.ts +++ b/app/api/organizations/artists/route.ts @@ -29,4 +29,3 @@ export async function OPTIONS() { export async function POST(request: NextRequest) { return addArtistToOrgHandler(request); } - diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts index b22e825..8693f08 100644 --- a/app/api/organizations/route.ts +++ b/app/api/organizations/route.ts @@ -45,4 +45,3 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return createOrganizationHandler(request); } - diff --git a/app/api/spotify/artist/albums/route.ts b/app/api/spotify/artist/albums/route.ts index b7d939f..b7ac94e 100644 --- a/app/api/spotify/artist/albums/route.ts +++ b/app/api/spotify/artist/albums/route.ts @@ -32,4 +32,3 @@ export async function OPTIONS() { export async function GET(request: NextRequest) { return getSpotifyArtistAlbumsHandler(request); } - diff --git a/app/api/transcribe/route.ts b/app/api/transcribe/route.ts index 5cf0b9a..0896806 100644 --- a/app/api/transcribe/route.ts +++ b/app/api/transcribe/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { processAudioTranscription } from "@/lib/transcribe/processAudioTranscription"; import { formatTranscriptionError } from "@/lib/transcribe/types"; +/** + * + * @param req + */ export async function POST(req: NextRequest) { try { const body = await req.json(); @@ -40,4 +44,3 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: message }, { status }); } } - diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts new file mode 100644 index 0000000..60d51a3 --- /dev/null +++ b/app/api/workspaces/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createWorkspacePostHandler } from "@/lib/workspaces/createWorkspacePostHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/workspaces + * + * Creates a new workspace account. + * + * Request body: + * - name (optional): The name of the workspace to create. Defaults to "Untitled". + * - account_id (optional): The ID of the account to create the workspace for (UUID). + * Only required for organization API keys creating workspaces on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new workspace to (UUID). + * If provided, the workspace will appear in that organization's view. + * Access is validated to ensure the user has access to the organization. + * + * Response: + * - 201: { workspace: WorkspaceObject } + * - 400: { status: "error", error: "validation error message" } + * - 401: { status: "error", error: "x-api-key header required" or "Invalid API key" } + * - 403: { status: "error", error: "Access denied to specified organization_id/account_id" } + * - 500: { status: "error", error: "Failed to create workspace" } + * + * @param request - The request object containing JSON body + * @returns A NextResponse with the created workspace data (201) or error + */ +export async function POST(request: NextRequest) { + return createWorkspacePostHandler(request); +} diff --git a/lib/accounts/getAccountHandler.ts b/lib/accounts/getAccountHandler.ts index 327242f..0c58a58 100644 --- a/lib/accounts/getAccountHandler.ts +++ b/lib/accounts/getAccountHandler.ts @@ -61,4 +61,3 @@ export async function getAccountHandler( ); } } - diff --git a/lib/accounts/validateAccountParams.ts b/lib/accounts/validateAccountParams.ts index 8b2c453..47864d2 100644 --- a/lib/accounts/validateAccountParams.ts +++ b/lib/accounts/validateAccountParams.ts @@ -34,4 +34,3 @@ export function validateAccountParams(id: string): NextResponse | AccountParams return result.data; } - diff --git a/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts index 0b0ce5c..a78aef8 100644 --- a/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts +++ b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts @@ -2,6 +2,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; import { ToolLoopAgent, stepCountIs } from "ai"; +// Import after mocks +import getGeneralAgent from "../getGeneralAgent"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; +import { setupToolsForRequest } from "@/lib/chat/setupToolsForRequest"; +import { getSystemPrompt } from "@/lib/prompts/getSystemPrompt"; +import { extractImageUrlsFromMessages } from "@/lib/messages/extractImageUrlsFromMessages"; +import { buildSystemPromptWithImages } from "@/lib/chat/buildSystemPromptWithImages"; + // Mock all external dependencies vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn(), @@ -35,17 +46,6 @@ vi.mock("@/lib/chat/buildSystemPromptWithImages", () => ({ buildSystemPromptWithImages: vi.fn(), })); -// Import after mocks -import getGeneralAgent from "../getGeneralAgent"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; -import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; -import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; -import { setupToolsForRequest } from "@/lib/chat/setupToolsForRequest"; -import { getSystemPrompt } from "@/lib/prompts/getSystemPrompt"; -import { extractImageUrlsFromMessages } from "@/lib/messages/extractImageUrlsFromMessages"; -import { buildSystemPromptWithImages } from "@/lib/chat/buildSystemPromptWithImages"; - const mockSelectAccountEmails = vi.mocked(selectAccountEmails); const mockSelectAccountInfo = vi.mocked(selectAccountInfo); const mockGetAccountWithDetails = vi.mocked(getAccountWithDetails); diff --git a/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts b/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts index 35364c8..e0aaae4 100644 --- a/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts +++ b/lib/agents/googleSheetsAgent/__tests__/getGoogleSheetsTools.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +// Import after mocks +import getGoogleSheetsTools from "../getGoogleSheetsTools"; +import { getComposioClient } from "@/lib/composio/client"; +import getConnectedAccount from "@/lib/composio/googleSheets/getConnectedAccount"; +import getLatestUserMessageText from "@/lib/messages/getLatestUserMessageText"; + // Mock external dependencies vi.mock("@/lib/composio/client", () => ({ getComposioClient: vi.fn(), @@ -19,12 +25,6 @@ vi.mock("@/lib/composio/tools/googleSheetsLoginTool", () => ({ default: { description: "Login to Google Sheets", parameters: {} }, })); -// Import after mocks -import getGoogleSheetsTools from "../getGoogleSheetsTools"; -import { getComposioClient } from "@/lib/composio/client"; -import getConnectedAccount from "@/lib/composio/googleSheets/getConnectedAccount"; -import getLatestUserMessageText from "@/lib/messages/getLatestUserMessageText"; - const mockGetComposioClient = vi.mocked(getComposioClient); const mockGetConnectedAccount = vi.mocked(getConnectedAccount); const mockGetLatestUserMessageText = vi.mocked(getLatestUserMessageText); @@ -62,10 +62,7 @@ describe("getGoogleSheetsTools", () => { await getGoogleSheetsTools(body); - expect(mockGetConnectedAccount).toHaveBeenCalledWith( - "account-123", - expect.any(Object), - ); + expect(mockGetConnectedAccount).toHaveBeenCalledWith("account-123", expect.any(Object)); }); it("passes callback URL with encoded latest user message", async () => { diff --git a/lib/ai/__tests__/getAvailableModels.test.ts b/lib/ai/__tests__/getAvailableModels.test.ts index 05a9014..271752b 100644 --- a/lib/ai/__tests__/getAvailableModels.test.ts +++ b/lib/ai/__tests__/getAvailableModels.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { gateway } from "@ai-sdk/gateway"; +import { getAvailableModels } from "../getAvailableModels"; + vi.mock("@ai-sdk/gateway", () => ({ gateway: { getAvailableModels: vi.fn(), }, })); -import { gateway } from "@ai-sdk/gateway"; -import { getAvailableModels } from "../getAvailableModels"; - const mockGatewayGetAvailableModels = vi.mocked(gateway.getAvailableModels); describe("getAvailableModels", () => { @@ -35,7 +35,7 @@ describe("getAvailableModels", () => { // Should filter out embed models (output price = 0) expect(models).toHaveLength(2); - expect(models.map((m) => m.id)).toEqual(["gpt-4", "claude-3-opus"]); + expect(models.map(m => m.id)).toEqual(["gpt-4", "claude-3-opus"]); }); it("returns empty array when gateway returns no models", async () => { diff --git a/lib/ai/__tests__/getModel.test.ts b/lib/ai/__tests__/getModel.test.ts index 52ec40c..367fa40 100644 --- a/lib/ai/__tests__/getModel.test.ts +++ b/lib/ai/__tests__/getModel.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getAvailableModels } from "@/lib/ai/getAvailableModels"; +import { getModel } from "../getModel"; + vi.mock("@/lib/ai/getAvailableModels", () => ({ getAvailableModels: vi.fn(), })); -import { getAvailableModels } from "@/lib/ai/getAvailableModels"; -import { getModel } from "../getModel"; - const mockGetAvailableModels = vi.mocked(getAvailableModels); describe("getModel", () => { @@ -35,9 +35,7 @@ describe("getModel", () => { }); it("returns undefined when model is not found", async () => { - const models = [ - { id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }, - ]; + const models = [{ id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }]; mockGetAvailableModels.mockResolvedValue(models as any); const model = await getModel("unknown-model"); diff --git a/lib/ai/getAvailableModels.ts b/lib/ai/getAvailableModels.ts index 8ecfb8f..a46fd79 100644 --- a/lib/ai/getAvailableModels.ts +++ b/lib/ai/getAvailableModels.ts @@ -5,12 +5,10 @@ import isEmbedModel from "./isEmbedModel"; * Returns the list of available LLMs from the Vercel AI Gateway. * Filters out embed models that are not suitable for chat. */ -export const getAvailableModels = async (): Promise< - GatewayLanguageModelEntry[] -> => { +export const getAvailableModels = async (): Promise => { try { const apiResponse = await gateway.getAvailableModels(); - const gatewayModels = apiResponse.models.filter((m) => !isEmbedModel(m)); + const gatewayModels = apiResponse.models.filter(m => !isEmbedModel(m)); return gatewayModels; } catch { return []; diff --git a/lib/ai/getModel.ts b/lib/ai/getModel.ts index b802aca..99ca9c2 100644 --- a/lib/ai/getModel.ts +++ b/lib/ai/getModel.ts @@ -3,15 +3,14 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Returns a specific model by its ID from the list of available models. + * * @param modelId - The ID of the model to find * @returns The matching model or undefined if not found */ -export const getModel = async ( - modelId: string, -): Promise => { +export const getModel = async (modelId: string): Promise => { try { const availableModels = await getAvailableModels(); - return availableModels.find((model) => model.id === modelId); + return availableModels.find(model => model.id === modelId); } catch (error) { console.error(`Failed to get model with ID ${modelId}:`, error); return undefined; diff --git a/lib/ai/isEmbedModel.ts b/lib/ai/isEmbedModel.ts index 7c5fbbf..4901f1e 100644 --- a/lib/ai/isEmbedModel.ts +++ b/lib/ai/isEmbedModel.ts @@ -3,6 +3,8 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Determines if a model is an embedding model (not suitable for chat). * Embed models typically have 0 output pricing since they only produce embeddings. + * + * @param m */ export const isEmbedModel = (m: GatewayLanguageModelEntry): boolean => { const pricing = m.pricing; diff --git a/lib/artist/validateArtistSocialsScrapeBody.ts b/lib/artist/validateArtistSocialsScrapeBody.ts index 7ef98c2..a6d5cf8 100644 --- a/lib/artist/validateArtistSocialsScrapeBody.ts +++ b/lib/artist/validateArtistSocialsScrapeBody.ts @@ -36,4 +36,3 @@ export function validateArtistSocialsScrapeBody( return validationResult.data; } - diff --git a/lib/artists/__tests__/createArtistInDb.test.ts b/lib/artists/__tests__/createArtistInDb.test.ts index e979fbb..eaed944 100644 --- a/lib/artists/__tests__/createArtistInDb.test.ts +++ b/lib/artists/__tests__/createArtistInDb.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createArtistInDb } from "../createArtistInDb"; + const mockInsertAccount = vi.fn(); const mockInsertAccountInfo = vi.fn(); const mockSelectAccountWithSocials = vi.fn(); @@ -26,8 +28,6 @@ vi.mock("@/lib/supabase/artist_organization_ids/addArtistToOrganization", () => addArtistToOrganization: (...args: unknown[]) => mockAddArtistToOrganization(...args), })); -import { createArtistInDb } from "../createArtistInDb"; - describe("createArtistInDb", () => { const mockAccount = { id: "artist-123", diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index 78ee84a..e63d244 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -1,31 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; + +import { createArtistPostHandler } from "../createArtistPostHandler"; const mockCreateArtistInDb = vi.fn(); -const mockGetApiKeyDetails = vi.fn(); -const mockCanAccessAccount = vi.fn(); +const mockValidateAuthContext = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), })); -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), -})); - -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); -import { createArtistPostHandler } from "../createArtistPostHandler"; - -function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { +function createRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }; return new NextRequest("http://localhost/api/artists", { method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, + headers: { ...defaultHeaders, ...headers }, body: JSON.stringify(body), }); } @@ -33,13 +29,15 @@ function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { describe("createArtistPostHandler", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetApiKeyDetails.mockResolvedValue({ + // Default mock: successful auth with personal API key + mockValidateAuthContext.mockResolvedValue({ accountId: "api-key-account-id", orgId: null, + authToken: "test-api-key", }); }); - it("creates artist using account_id from API key", async () => { + it("creates artist using account_id from auth context", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -63,11 +61,11 @@ describe("createArtistPostHandler", () => { }); it("uses account_id override for org API keys", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", + mockValidateAuthContext.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", // Overridden account orgId: "org-account-id", + authToken: "test-api-key", }); - mockCanAccessAccount.mockResolvedValue(true); const mockArtist = { id: "artist-123", @@ -84,10 +82,6 @@ describe("createArtistPostHandler", () => { }); const response = await createArtistPostHandler(request); - expect(mockCanAccessAccount).toHaveBeenCalledWith({ - orgId: "org-account-id", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", - }); expect(mockCreateArtistInDb).toHaveBeenCalledWith( "Test Artist", "550e8400-e29b-41d4-a716-446655440000", @@ -97,11 +91,12 @@ describe("createArtistPostHandler", () => { }); it("returns 403 when org API key lacks access to account_id", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", - }); - mockCanAccessAccount.mockResolvedValue(false); + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); const request = createRequest({ name: "Test Artist", @@ -136,7 +131,14 @@ describe("createArtistPostHandler", () => { ); }); - it("returns 401 when API key is missing", async () => { + it("returns 401 when auth is missing", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const request = new NextRequest("http://localhost/api/artists", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -147,18 +149,7 @@ describe("createArtistPostHandler", () => { const data = await response.json(); expect(response.status).toBe(401); - expect(data.error).toBe("x-api-key header required"); - }); - - it("returns 401 when API key is invalid", async () => { - mockGetApiKeyDetails.mockResolvedValue(null); - - const request = createRequest({ name: "Test Artist" }); - const response = await createArtistPostHandler(request); - const data = await response.json(); - - expect(response.status).toBe(401); - expect(data.error).toBe("Invalid API key"); + expect(data.error).toBe("Exactly one of x-api-key or Authorization must be provided"); }); it("returns 400 when name is missing", async () => { diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index cc619b0..4de5562 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -1,27 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -const mockGetApiKeyDetails = vi.fn(); -const mockCanAccessAccount = vi.fn(); +import { validateCreateArtistBody } from "../validateCreateArtistBody"; -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), -})); +const mockValidateAuthContext = vi.fn(); -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); -import { validateCreateArtistBody } from "../validateCreateArtistBody"; - -function createRequest(body: unknown, apiKey?: string): NextRequest { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) { - headers["x-api-key"] = apiKey; - } +function createRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/artists", { method: "POST", - headers, + headers: { ...defaultHeaders, ...headers }, body: JSON.stringify(body), }); } @@ -29,173 +21,236 @@ function createRequest(body: unknown, apiKey?: string): NextRequest { describe("validateCreateArtistBody", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetApiKeyDetails.mockResolvedValue({ + // Default mock: successful auth with personal API key + mockValidateAuthContext.mockResolvedValue({ accountId: "api-key-account-id", orgId: null, + authToken: "test-api-key", }); }); - it("returns validated data with accountId from API key", async () => { - const request = createRequest({ name: "Test Artist" }, "test-api-key"); - const result = await validateCreateArtistBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.name).toBe("Test Artist"); - expect(result.accountId).toBe("api-key-account-id"); - expect(result.organizationId).toBeUndefined(); - } - }); - - it("returns validated data with organization_id", async () => { - const request = createRequest( - { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.name).toBe("Test Artist"); - expect(result.accountId).toBe("api-key-account-id"); - expect(result.organizationId).toBe("660e8400-e29b-41d4-a716-446655440001"); - } - }); - - it("uses account_id override for org API keys with access", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", + describe("successful validation", () => { + it("returns validated data with accountId from auth context", async () => { + const request = createRequest({ name: "Test Artist" }, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBeUndefined(); + } }); - mockCanAccessAccount.mockResolvedValue(true); - const request = createRequest( - { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); + it("returns validated data with organization_id", async () => { + mockValidateAuthContext.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: "660e8400-e29b-41d4-a716-446655440001", + authToken: "test-api-key", + }); + + const request = createRequest( + { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBe("660e8400-e29b-41d4-a716-446655440001"); + } + }); - expect(mockCanAccessAccount).toHaveBeenCalledWith({ - orgId: "org-account-id", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + it("uses account_id override for org API keys with access", async () => { + mockValidateAuthContext.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", // Overridden account + orgId: "org-account-id", + authToken: "test-api-key", + }); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + // Verify validateAuthContext was called with account_id override + expect(mockValidateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + }), + ); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); + } }); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); - } }); - it("returns 403 when org API key lacks access to account_id", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", + describe("auth errors", () => { + it("returns 401 when auth is missing", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + + const request = createRequest({ name: "Test Artist" }); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } }); - mockCanAccessAccount.mockResolvedValue(false); - - const request = createRequest( - { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(403); - const data = await result.json(); - expect(data.error).toBe("Access denied to specified account_id"); - } - }); - it("returns 401 when API key is missing", async () => { - const request = createRequest({ name: "Test Artist" }); - const result = await validateCreateArtistBody(request); + it("returns 403 when org API key lacks access to account_id", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const data = await result.json(); + expect(data.error).toBe("Access denied to specified account_id"); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - const data = await result.json(); - expect(data.error).toBe("x-api-key header required"); - } + it("returns 403 when account lacks access to organization_id", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified organization_id" }, + { status: 403 }, + ), + ); + + const request = createRequest( + { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const data = await result.json(); + expect(data.error).toBe("Access denied to specified organization_id"); + } + }); }); - it("returns 401 when API key is invalid", async () => { - mockGetApiKeyDetails.mockResolvedValue(null); - - const request = createRequest({ name: "Test Artist" }, "invalid-key"); - const result = await validateCreateArtistBody(request); + describe("schema validation errors", () => { + it("returns schema error for invalid JSON body (treated as empty)", async () => { + const request = new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }, + body: "invalid json", + }); + + const result = await validateCreateArtistBody(request); + + // safeParseJson returns {} for invalid JSON, so schema validation catches it + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBe("name is required"); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - const data = await result.json(); - expect(data.error).toBe("Invalid API key"); - } - }); + it("returns error when name is missing", async () => { + const request = createRequest({}, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); - it("returns schema error for invalid JSON body (treated as empty)", async () => { - const request = new NextRequest("http://localhost/api/artists", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": "test-api-key", - }, - body: "invalid json", + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } }); - const result = await validateCreateArtistBody(request); + it("returns error when name is empty", async () => { + const request = createRequest({ name: "" }, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); - // safeParseJson returns {} for invalid JSON, so schema validation catches it - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const data = await result.json(); - expect(data.error).toBe("name is required"); - } - }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); - it("returns error when name is missing", async () => { - const request = createRequest({}, "test-api-key"); - const result = await validateCreateArtistBody(request); + it("returns error when account_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", account_id: "invalid-uuid" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } + it("returns error when organization_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", organization_id: "invalid-uuid" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); }); - it("returns error when name is empty", async () => { - const request = createRequest({ name: "" }, "test-api-key"); - const result = await validateCreateArtistBody(request); + describe("auth context input", () => { + it("passes account_id and organization_id to validateAuthContext", async () => { + const request = createRequest( + { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }, + { "x-api-key": "test-api-key" }, + ); + + await validateCreateArtistBody(request); + + expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { + accountId: "550e8400-e29b-41d4-a716-446655440000", + organizationId: "660e8400-e29b-41d4-a716-446655440001", + }); + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); + it("passes undefined for missing account_id and organization_id", async () => { + const request = createRequest({ name: "Test Artist" }, { "x-api-key": "test-api-key" }); - it("returns error when account_id is not a valid UUID", async () => { - const request = createRequest( - { name: "Test Artist", account_id: "invalid-uuid" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); + await validateCreateArtistBody(request); - it("returns error when organization_id is not a valid UUID", async () => { - const request = createRequest( - { name: "Test Artist", organization_id: "invalid-uuid" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } + expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { + accountId: undefined, + organizationId: undefined, + }); + }); }); }); diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts index 58bed3f..80cb3ad 100644 --- a/lib/artists/createArtistPostHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -19,9 +19,7 @@ import { createArtistInDb } from "@/lib/artists/createArtistInDb"; * @param request - The request object containing JSON body * @returns A NextResponse with artist data or error */ -export async function createArtistPostHandler( - request: NextRequest, -): Promise { +export async function createArtistPostHandler(request: NextRequest): Promise { const validated = await validateCreateArtistBody(request); if (validated instanceof NextResponse) { return validated; @@ -41,10 +39,7 @@ export async function createArtistPostHandler( ); } - return NextResponse.json( - { artist }, - { status: 201, headers: getCorsHeaders() }, - ); + return NextResponse.json({ artist }, { status: 201, headers: getCorsHeaders() }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create artist"; return NextResponse.json( diff --git a/lib/artists/getArtistsHandler.ts b/lib/artists/getArtistsHandler.ts index ac86334..f7c0029 100644 --- a/lib/artists/getArtistsHandler.ts +++ b/lib/artists/getArtistsHandler.ts @@ -55,4 +55,3 @@ export async function getArtistsHandler(request: NextRequest): Promise ({ - ...social.social, - link: social.social?.profile_url || "", - type: getSocialPlatformByLink(social.social?.profile_url || ""), - }), - ); + const account_socials = (artist.account_socials || []).map((social: AccountSocialWithSocial) => ({ + ...social.social, + link: social.social?.profile_url || "", + type: getSocialPlatformByLink(social.social?.profile_url || ""), + })); return { name: artist.name, @@ -76,4 +74,3 @@ export function getFormattedArtist(row: ArtistQueryRow): FormattedArtist { pinned: row.pinned || false, }; } - diff --git a/lib/artists/getSocialPlatformByLink.ts b/lib/artists/getSocialPlatformByLink.ts index aaf6ab6..744ad62 100644 --- a/lib/artists/getSocialPlatformByLink.ts +++ b/lib/artists/getSocialPlatformByLink.ts @@ -17,4 +17,3 @@ export function getSocialPlatformByLink(link: string): string { return "NONE"; } - diff --git a/lib/artists/validateArtistsQuery.ts b/lib/artists/validateArtistsQuery.ts index 9c02188..91b5a90 100644 --- a/lib/artists/validateArtistsQuery.ts +++ b/lib/artists/validateArtistsQuery.ts @@ -37,4 +37,3 @@ export function validateArtistsQuery(searchParams: URLSearchParams): NextRespons return result.data; } - diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts index 6b4e0b8..2515d11 100644 --- a/lib/artists/validateCreateArtistBody.ts +++ b/lib/artists/validateCreateArtistBody.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; @@ -20,8 +19,12 @@ export type ValidatedCreateArtistRequest = { }; /** - * Validates POST /api/artists request including API key, body parsing, schema validation, - * and account access authorization. + * Validates POST /api/artists request including auth headers, body parsing, schema validation, + * account access authorization, and organization access authorization. + * + * Supports both: + * - x-api-key header + * - Authorization: Bearer header * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated request data if validation passes. @@ -29,22 +32,7 @@ export type ValidatedCreateArtistRequest = { export async function validateCreateArtistBody( request: NextRequest, ): Promise { - const apiKey = request.headers.get("x-api-key"); - if (!apiKey) { - return NextResponse.json( - { status: "error", error: "x-api-key header required" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - - const keyDetails = await getApiKeyDetails(apiKey); - if (!keyDetails) { - return NextResponse.json( - { status: "error", error: "Invalid API key" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - + // Parse and validate the request body first const body = await safeParseJson(request); const result = createArtistBodySchema.safeParse(body); if (!result.success) { @@ -59,25 +47,19 @@ export async function validateCreateArtistBody( ); } - // Use account_id from body if provided (org API keys only), otherwise use API key's account - let accountId = keyDetails.accountId; - if (result.data.account_id) { - const hasAccess = await canAccessAccount({ - orgId: keyDetails.orgId, - targetAccountId: result.data.account_id, - }); - if (!hasAccess) { - return NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } - accountId = result.data.account_id; + // Validate auth and authorization using the centralized utility + const authContext = await validateAuthContext(request, { + accountId: result.data.account_id, + organizationId: result.data.organization_id, + }); + + if (authContext instanceof NextResponse) { + return authContext; } return { name: result.data.name, - accountId, + accountId: authContext.accountId, organizationId: result.data.organization_id, }; } diff --git a/lib/arweave/isIPFSUrl.ts b/lib/arweave/isIPFSUrl.ts index ba9e548..271ce35 100644 --- a/lib/arweave/isIPFSUrl.ts +++ b/lib/arweave/isIPFSUrl.ts @@ -10,4 +10,3 @@ import { isGatewayIPFSUrl } from "./isGatewayIPFSUrl"; export function isIPFSUrl(url: string | null | undefined): boolean { return url ? isNormalizedIPFSURL(url) || isGatewayIPFSUrl(url) : false; } - diff --git a/lib/arweave/isNormalizedIPFSURL.ts b/lib/arweave/isNormalizedIPFSURL.ts index cdfbe37..7275e64 100644 --- a/lib/arweave/isNormalizedIPFSURL.ts +++ b/lib/arweave/isNormalizedIPFSURL.ts @@ -7,4 +7,3 @@ export function isNormalizedIPFSURL(url: string | null | undefined): boolean { return url && typeof url === "string" ? url.startsWith("ipfs://") : false; } - diff --git a/lib/auth/__tests__/validateAuthContext.test.ts b/lib/auth/__tests__/validateAuthContext.test.ts new file mode 100644 index 0000000..911ecbc --- /dev/null +++ b/lib/auth/__tests__/validateAuthContext.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAuthContext } from "../validateAuthContext"; + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +// Mock dependencies +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ + validateOrganizationAccess: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); +const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails); +const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); +const mockCanAccessAccount = vi.mocked(canAccessAccount); + +function createMockRequest(headers: Record = {}): Request { + return { + headers: { + get: (name: string) => headers[name.toLowerCase()] || null, + }, + } as unknown as Request; +} + +describe("validateAuthContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("authentication mechanism validation", () => { + it("returns 401 when neither x-api-key nor Authorization is provided", async () => { + const request = createMockRequest({}); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("returns 401 when both x-api-key and Authorization are provided", async () => { + const request = createMockRequest({ + "x-api-key": "test-api-key", + authorization: "Bearer test-token", + }); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + }); + + describe("API key authentication", () => { + it("returns accountId from API key when valid", async () => { + const request = createMockRequest({ "x-api-key": "valid-api-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "test-key", + }); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: null, + authToken: "valid-api-key", + }); + }); + + it("returns orgId from API key details when present", async () => { + const request = createMockRequest({ "x-api-key": "org-api-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "org-account-123", + orgId: "org-456", + authToken: "org-api-key", + }); + }); + + it("returns error response when API key is invalid", async () => { + const request = createMockRequest({ "x-api-key": "invalid-key" }); + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json({ error: "Invalid API key" }, { status: 401 }), + ); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + }); + + describe("Bearer token authentication", () => { + it("returns accountId from bearer token when valid", async () => { + const request = createMockRequest({ authorization: "Bearer valid-token" }); + mockGetAuthenticatedAccountId.mockResolvedValue("bearer-account-123"); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "bearer-account-123", + orgId: null, + authToken: "valid-token", + }); + }); + + it("strips Bearer prefix from auth token", async () => { + const request = createMockRequest({ authorization: "Bearer my-token-123" }); + mockGetAuthenticatedAccountId.mockResolvedValue("account-123"); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + const authContext = result as { authToken: string }; + expect(authContext.authToken).toBe("my-token-123"); + }); + }); + + describe("account_id override", () => { + it("allows personal API key to specify own account_id (self-access)", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + accountId: "account-123", // Same as the API key's account + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: null, + authToken: "personal-key", + }); + // Should not call canAccessAccount for self-access + expect(mockCanAccessAccount).not.toHaveBeenCalled(); + }); + + it("denies personal API key accessing different account_id", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + accountId: "different-account-456", // Different from API key's account + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("Access denied to specified account_id"); + }); + + it("allows org API key to access member account", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + accountId: "member-account-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "member-account-789", + orgId: "org-456", + authToken: "org-key", + }); + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-456", + targetAccountId: "member-account-789", + }); + }); + + it("denies org API key accessing non-member account", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(false); + + const result = await validateAuthContext(request as never, { + accountId: "non-member-account", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + }); + + describe("organization_id validation", () => { + it("allows access when account is a member of the organization", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + organizationId: "org-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: "org-789", // orgId is set from organizationId input + authToken: "personal-key", + }); + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "account-123", + organizationId: "org-789", + }); + }); + + it("denies access when account is NOT a member of the organization", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + mockValidateOrganizationAccess.mockResolvedValue(false); + + const result = await validateAuthContext(request as never, { + organizationId: "org-not-member", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("Access denied to specified organization_id"); + }); + + it("skips organization validation when organizationId is null", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + organizationId: null, + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(mockValidateOrganizationAccess).not.toHaveBeenCalled(); + }); + }); + + describe("combined account_id and organization_id validation", () => { + it("validates organization access using the overridden accountId", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-admin-account"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-admin-account", + orgId: "org-123", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(true); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + accountId: "member-account-456", + organizationId: "different-org-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + // Verify that organization access was validated with the overridden accountId + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "member-account-456", // The overridden account + organizationId: "different-org-789", + }); + }); + }); +}); diff --git a/lib/auth/validateAuthContext.ts b/lib/auth/validateAuthContext.ts new file mode 100644 index 0000000..ae844dc --- /dev/null +++ b/lib/auth/validateAuthContext.ts @@ -0,0 +1,190 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +/** + * Input parameters for auth context validation. + * These typically come from the request body after schema validation. + */ +export interface AuthContextInput { + /** Optional account_id override from request body */ + accountId?: string; + /** Optional organization_id from request body */ + organizationId?: string | null; +} + +/** + * Validated auth context result. + * Contains the resolved accountId, orgId, and auth token. + */ +export interface AuthContext { + /** The resolved account ID (from API key or override) */ + accountId: string; + /** The organization context (from API key or request body) */ + orgId: string | null; + /** The auth token for forwarding to downstream services */ + authToken: string; +} + +/** + * Validates authentication headers and authorization context for API requests. + * + * This is the single source of truth for: + * 1. Authenticating via x-api-key or Authorization bearer token + * 2. Resolving the accountId (from auth or body override) + * 3. Validating account_id override access (org keys can access member accounts, personal keys can access own account) + * 4. Validating organization_id access (account must be a member of the org) + * + * @param request - The NextRequest object + * @param input - Optional overrides from the request body + * @returns A NextResponse with an error or the validated AuthContext + */ +export async function validateAuthContext( + request: NextRequest, + input: AuthContextInput = {}, +): Promise { + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce exactly one auth mechanism + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + let accountId: string; + let orgId: string | null = null; + let authToken: string; + + if (hasApiKey) { + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + authToken = apiKey!; + + // Get org context from API key details + const keyDetails = await getApiKeyDetails(apiKey!); + if (keyDetails) { + orgId = keyDetails.orgId; + } + } else { + // Validate bearer token authentication + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + authToken = authHeader!.replace(/^Bearer\s+/i, ""); + } + + // Handle account_id override from request body + if (input.accountId) { + const overrideResult = await validateAccountIdOverride({ + currentAccountId: accountId, + targetAccountId: input.accountId, + orgId, + }); + + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + + accountId = overrideResult.accountId; + } + + // Handle organization_id from request body + if (input.organizationId) { + const hasOrgAccess = await validateOrganizationAccess({ + accountId, + organizationId: input.organizationId, + }); + + if (!hasOrgAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified organization_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + // Use the provided organizationId as the org context + orgId = input.organizationId; + } + + return { + accountId, + orgId, + authToken, + }; +} + +/** + * Parameters for account ID override validation. + */ +interface ValidateAccountIdOverrideParams { + /** The account ID from the authenticated API key/token */ + currentAccountId: string; + /** The target account ID to override to */ + targetAccountId: string; + /** The organization ID from the API key (null for personal keys) */ + orgId: string | null; +} + +/** + * Result of successful account ID override validation. + */ +interface ValidateAccountIdOverrideResult { + accountId: string; +} + +/** + * Validates if an account_id override is allowed. + * + * Access rules: + * 1. If targetAccountId equals currentAccountId, always allowed (self-access) + * 2. If orgId is present, checks if targetAccountId is a member of the org + * 3. If orgId is null and targetAccountId !== currentAccountId, denied + * + * @param params - The validation parameters + * @returns NextResponse with error or the validated result + */ +async function validateAccountIdOverride( + params: ValidateAccountIdOverrideParams, +): Promise { + const { currentAccountId, targetAccountId, orgId } = params; + + // Self-access is always allowed (personal API key accessing own account) + if (targetAccountId === currentAccountId) { + return { accountId: targetAccountId }; + } + + // For org API keys, check if target account is a member of the org + if (orgId) { + const hasAccess = await canAccessAccount({ + orgId, + targetAccountId, + }); + + if (hasAccess) { + return { accountId: targetAccountId }; + } + } + + // No access - either personal key trying to access another account, + // or org key trying to access a non-member account + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); +} diff --git a/lib/chat/__tests__/buildSystemPromptWithImages.test.ts b/lib/chat/__tests__/buildSystemPromptWithImages.test.ts index 37717ce..7f5e6d4 100644 --- a/lib/chat/__tests__/buildSystemPromptWithImages.test.ts +++ b/lib/chat/__tests__/buildSystemPromptWithImages.test.ts @@ -57,7 +57,7 @@ describe("buildSystemPromptWithImages", () => { const result = buildSystemPromptWithImages(basePrompt, imageUrls); const lines = result.split("\n"); - const imageLines = lines.filter((line) => line.startsWith("- Image")); + const imageLines = lines.filter(line => line.startsWith("- Image")); expect(imageLines).toHaveLength(2); }); }); diff --git a/lib/chat/__tests__/handleChatCompletion.test.ts b/lib/chat/__tests__/handleChatCompletion.test.ts index ad9a967..1ea34d2 100644 --- a/lib/chat/__tests__/handleChatCompletion.test.ts +++ b/lib/chat/__tests__/handleChatCompletion.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { UIMessage } from "ai"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import upsertMemory from "@/lib/supabase/memories/upsertMemory"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import { generateChatTitle } from "@/lib/chat/generateChatTitle"; +import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; +import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification"; +import { handleChatCompletion } from "../handleChatCompletion"; +import type { ChatRequestBody } from "../validateChatRequest"; + // Mock all dependencies before importing the module under test vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn(), @@ -34,17 +45,6 @@ vi.mock("@/lib/telegram/sendErrorNotification", () => ({ sendErrorNotification: vi.fn(), })); -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; -import upsertMemory from "@/lib/supabase/memories/upsertMemory"; -import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; -import { generateChatTitle } from "@/lib/chat/generateChatTitle"; -import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; -import { sendErrorNotification } from "@/lib/telegram/sendErrorNotification"; -import { handleChatCompletion } from "../handleChatCompletion"; -import type { ChatRequestBody } from "../validateChatRequest"; - const mockSelectAccountEmails = vi.mocked(selectAccountEmails); const mockSelectRoom = vi.mocked(selectRoom); const mockInsertRoom = vi.mocked(insertRoom); @@ -55,6 +55,12 @@ const mockHandleSendEmailToolOutputs = vi.mocked(handleSendEmailToolOutputs); const mockSendErrorNotification = vi.mocked(sendErrorNotification); // Helper to create mock UIMessage +/** + * + * @param id + * @param role + * @param text + */ function createMockUIMessage(id: string, role: "user" | "assistant", text: string): UIMessage { return { id, @@ -65,6 +71,10 @@ function createMockUIMessage(id: string, role: "user" | "assistant", text: strin } // Helper to create mock ChatRequestBody +/** + * + * @param overrides + */ function createMockBody(overrides: Partial = {}): ChatRequestBody { return { accountId: "account-123", @@ -122,7 +132,7 @@ describe("handleChatCompletion", () => { const responseMessages = [createMockUIMessage("resp-1", "assistant", "Hi there!")]; const callOrder: string[] = []; - mockUpsertMemory.mockImplementation(async (params) => { + mockUpsertMemory.mockImplementation(async params => { callOrder.push(params.id); return null; }); diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index 8ee8827..5e8bfb7 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextResponse } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { setupChatRequest } from "@/lib/chat/setupChatRequest"; +import { generateText } from "ai"; +import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import { handleChatGenerate } from "../handleChatGenerate"; + // Mock all dependencies before importing the module under test vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), @@ -54,15 +63,6 @@ vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ default: vi.fn((msg: unknown) => msg), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { setupChatRequest } from "@/lib/chat/setupChatRequest"; -import { generateText } from "ai"; -import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; -import { generateUUID } from "@/lib/uuid/generateUUID"; -import { createNewRoom } from "@/lib/chat/createNewRoom"; -import { handleChatGenerate } from "../handleChatGenerate"; - const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); const mockSetupChatRequest = vi.mocked(setupChatRequest); @@ -72,10 +72,12 @@ const mockGenerateUUID = vi.mocked(generateUUID); const mockCreateNewRoom = vi.mocked(createNewRoom); // Helper to create mock NextRequest -function createMockRequest( - body: unknown, - headers: Record = {}, -): Request { +/** + * + * @param body + * @param headers + */ +function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), headers: { @@ -98,10 +100,7 @@ describe("handleChatGenerate", () => { it("returns 400 error when neither messages nor prompt is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "test-key" }, - ); + const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); const result = await handleChatGenerate(request as any); @@ -152,10 +151,7 @@ describe("handleChatGenerate", () => { }, } as any); - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "valid-key" }); const result = await handleChatGenerate(request as any); @@ -187,10 +183,7 @@ describe("handleChatGenerate", () => { } as any); const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest( - { messages }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ messages }, { "x-api-key": "valid-key" }); await handleChatGenerate(request as any); @@ -267,10 +260,7 @@ describe("handleChatGenerate", () => { response: { messages: [], headers: {}, body: null }, } as any); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await handleChatGenerate(request as any); @@ -286,10 +276,7 @@ describe("handleChatGenerate", () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await handleChatGenerate(request as any); @@ -314,10 +301,7 @@ describe("handleChatGenerate", () => { mockGenerateText.mockRejectedValue(new Error("Generation failed")); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await handleChatGenerate(request as any); @@ -427,10 +411,7 @@ describe("handleChatGenerate", () => { mockSaveChatCompletion.mockResolvedValue(null); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); await handleChatGenerate(request as any); @@ -499,10 +480,7 @@ describe("handleChatGenerate", () => { mockSaveChatCompletion.mockResolvedValue(null); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await handleChatGenerate(request as any); diff --git a/lib/chat/__tests__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts index b29127d..ab2e1b3 100644 --- a/lib/chat/__tests__/handleChatStream.test.ts +++ b/lib/chat/__tests__/handleChatStream.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextResponse } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { setupChatRequest } from "@/lib/chat/setupChatRequest"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { handleChatStream } from "../handleChatStream"; + // Mock all dependencies before importing the module under test vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), @@ -23,18 +30,20 @@ vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ })); vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn().mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }), + setupConversation: vi + .fn() + .mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }), })); vi.mock("@/lib/chat/validateMessages", () => ({ - validateMessages: vi.fn((messages) => ({ + validateMessages: vi.fn(messages => ({ lastMessage: messages[messages.length - 1] || { id: "mock-id", role: "user", parts: [] }, validMessages: messages, })), })); vi.mock("@/lib/messages/convertToUiMessages", () => ({ - default: vi.fn((messages) => messages), + default: vi.fn(messages => messages), })); vi.mock("@/lib/chat/setupChatRequest", () => ({ @@ -50,13 +59,6 @@ vi.mock("ai", () => ({ createUIMessageStreamResponse: vi.fn(), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { setupChatRequest } from "@/lib/chat/setupChatRequest"; -import { setupConversation } from "@/lib/chat/setupConversation"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { handleChatStream } from "../handleChatStream"; - const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); const mockSetupConversation = vi.mocked(setupConversation); @@ -65,10 +67,12 @@ const mockCreateUIMessageStream = vi.mocked(createUIMessageStream); const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse); // Helper to create mock NextRequest -function createMockRequest( - body: unknown, - headers: Record = {}, -): Request { +/** + * + * @param body + * @param headers + */ +function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), headers: { @@ -97,10 +101,7 @@ describe("handleChatStream", () => { it("returns 400 error when neither messages nor prompt is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "test-key" }, - ); + const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); const result = await handleChatStream(request as any); @@ -151,10 +152,7 @@ describe("handleChatStream", () => { const mockResponse = new Response(mockStream); mockCreateUIMessageStreamResponse.mockReturnValue(mockResponse); - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "valid-key" }); const result = await handleChatStream(request as any); @@ -165,7 +163,8 @@ describe("handleChatStream", () => { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With, x-api-key", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, X-Requested-With, x-api-key", }, }); expect(result).toBe(mockResponse); @@ -198,10 +197,7 @@ describe("handleChatStream", () => { mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest( - { messages }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ messages }, { "x-api-key": "valid-key" }); await handleChatStream(request as any); @@ -268,10 +264,7 @@ describe("handleChatStream", () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await handleChatStream(request as any); diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 185e0e9..b915802 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -1,6 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextResponse } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import upsertMemory from "@/lib/supabase/memories/upsertMemory"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; +import { getCreditUsage } from "@/lib/credits/getCreditUsage"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { generateChatTitle } from "../../generateChatTitle"; +import { handleChatCompletion } from "../../handleChatCompletion"; +import { handleChatCredits } from "@/lib/credits/handleChatCredits"; +import { validateChatRequest } from "../../validateChatRequest"; +import { setupChatRequest } from "../../setupChatRequest"; + /** * Integration tests for chat endpoints. * @@ -137,24 +155,6 @@ vi.mock("ai", () => ({ })), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; -import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; -import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; -import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; -import upsertMemory from "@/lib/supabase/memories/upsertMemory"; -import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; -import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; -import { getCreditUsage } from "@/lib/credits/getCreditUsage"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { generateChatTitle } from "../../generateChatTitle"; -import { handleChatCompletion } from "../../handleChatCompletion"; -import { handleChatCredits } from "@/lib/credits/handleChatCredits"; -import { validateChatRequest } from "../../validateChatRequest"; -import { setupChatRequest } from "../../setupChatRequest"; - const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockSelectAccountEmails = vi.mocked(selectAccountEmails); const mockSelectAccountInfo = vi.mocked(selectAccountInfo); @@ -170,10 +170,12 @@ const mockDeductCredits = vi.mocked(deductCredits); const mockGenerateChatTitle = vi.mocked(generateChatTitle); // Helper to create mock NextRequest -function createMockRequest( - body: unknown, - headers: Record = {}, -): Request { +/** + * + * @param body + * @param headers + */ +function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), headers: { @@ -210,10 +212,7 @@ describe("Chat Integration Tests", () => { it("validates and returns body for valid request with prompt", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await validateChatRequest(request as any); @@ -251,16 +250,10 @@ describe("Chat Integration Tests", () => { it("returns 401 when API key lookup fails", async () => { // getApiKeyAccountId returns a NextResponse when authentication fails mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "Unauthorized" }, - { status: 401 }, - ), + NextResponse.json({ status: "error", message: "Unauthorized" }, { status: 401 }), ); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "invalid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "invalid-key" }); const result = await validateChatRequest(request as any); @@ -271,10 +264,7 @@ describe("Chat Integration Tests", () => { it("returns 400 when neither messages nor prompt is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "valid-key" }); const result = await validateChatRequest(request as any); @@ -420,9 +410,7 @@ describe("Chat Integration Tests", () => { mockGenerateChatTitle.mockResolvedValue("New Chat Title"); const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }], roomId: "new-room-123", accountId: "account-123", }; @@ -447,9 +435,7 @@ describe("Chat Integration Tests", () => { mockSelectRoom.mockResolvedValue({ id: "existing-room" } as any); const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }], roomId: "existing-room", accountId: "account-123", }; @@ -468,9 +454,7 @@ describe("Chat Integration Tests", () => { mockSelectRoom.mockResolvedValue({ id: "room-123" } as any); const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }], roomId: "room-123", accountId: "account-123", }; @@ -500,9 +484,7 @@ describe("Chat Integration Tests", () => { mockSelectRoom.mockResolvedValue({ id: "room-123" } as any); const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Send an email" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Send an email" }] }], roomId: "room-123", accountId: "account-123", }; @@ -530,9 +512,7 @@ describe("Chat Integration Tests", () => { mockSelectRoom.mockRejectedValue(new Error("Database error")); const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }], roomId: "room-123", accountId: "account-123", }; @@ -549,9 +529,7 @@ describe("Chat Integration Tests", () => { it("handles empty roomId by defaulting to empty string", async () => { const body = { - messages: [ - { id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }, - ], + messages: [{ id: "msg-1", role: "user", parts: [{ type: "text", text: "Hello" }] }], accountId: "account-123", // roomId not provided }; @@ -643,10 +621,7 @@ describe("Chat Integration Tests", () => { it("validates prompt-based requests through full pipeline", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { prompt: "What is 2+2?" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "What is 2+2?" }, { "x-api-key": "valid-key" }); const validationResult = await validateChatRequest(request as any); expect(validationResult).not.toBeInstanceOf(NextResponse); diff --git a/lib/chat/__tests__/saveChatCompletion.test.ts b/lib/chat/__tests__/saveChatCompletion.test.ts index f25d717..ddd995f 100644 --- a/lib/chat/__tests__/saveChatCompletion.test.ts +++ b/lib/chat/__tests__/saveChatCompletion.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getMessages } from "@/lib/messages/getMessages"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import { saveChatCompletion } from "../saveChatCompletion"; + // Mock dependencies before importing the module under test vi.mock("@/lib/messages/getMessages", () => ({ getMessages: vi.fn(), @@ -13,11 +18,6 @@ vi.mock("@/lib/supabase/memories/insertMemories", () => ({ default: vi.fn(), })); -import { getMessages } from "@/lib/messages/getMessages"; -import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; -import insertMemories from "@/lib/supabase/memories/insertMemories"; -import { saveChatCompletion } from "../saveChatCompletion"; - const mockGetMessages = vi.mocked(getMessages); const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); const mockInsertMemories = vi.mocked(insertMemories); diff --git a/lib/chat/__tests__/setupChatRequest.test.ts b/lib/chat/__tests__/setupChatRequest.test.ts index 96ca719..6523720 100644 --- a/lib/chat/__tests__/setupChatRequest.test.ts +++ b/lib/chat/__tests__/setupChatRequest.test.ts @@ -1,25 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatRequestBody } from "../validateChatRequest"; +import { setupChatRequest } from "../setupChatRequest"; +import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; +import { convertToModelMessages } from "ai"; + // Mock dependencies vi.mock("@/lib/agents/generalAgent/getGeneralAgent", () => ({ default: vi.fn(), })); -vi.mock("ai", async (importOriginal) => { +vi.mock("ai", async importOriginal => { const actual = (await importOriginal()) as Record; return { ...actual, - convertToModelMessages: vi.fn((messages) => messages), + convertToModelMessages: vi.fn(messages => messages), stepCountIs: actual.stepCountIs, ToolLoopAgent: actual.ToolLoopAgent, }; }); -import { setupChatRequest } from "../setupChatRequest"; -import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; -import { convertToModelMessages } from "ai"; - const mockGetGeneralAgent = vi.mocked(getGeneralAgent); const mockConvertToModelMessages = vi.mocked(convertToModelMessages); @@ -39,7 +39,7 @@ describe("setupChatRequest", () => { beforeEach(() => { vi.clearAllMocks(); mockGetGeneralAgent.mockResolvedValue(mockRoutingDecision as any); - mockConvertToModelMessages.mockImplementation((messages) => messages as any); + mockConvertToModelMessages.mockImplementation(messages => messages as any); }); describe("basic functionality", () => { @@ -280,7 +280,11 @@ describe("setupChatRequest", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "create_new_artist", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "create_new_artist", + output: { type: "json", value: {} }, + }, ], }, ], @@ -309,7 +313,11 @@ describe("setupChatRequest", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "create_new_artist", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "create_new_artist", + output: { type: "json", value: {} }, + }, ], }, ], diff --git a/lib/chat/__tests__/setupConversation.test.ts b/lib/chat/__tests__/setupConversation.test.ts index 04dab1f..001e5a0 100644 --- a/lib/chat/__tests__/setupConversation.test.ts +++ b/lib/chat/__tests__/setupConversation.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { setupConversation } from "../setupConversation"; + // Mock dependencies vi.mock("@/lib/uuid/generateUUID", () => { const mockFn = vi.fn(() => "mock-uuid"); @@ -25,13 +32,6 @@ vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ default: vi.fn(), })); -import { generateUUID } from "@/lib/uuid/generateUUID"; -import { createNewRoom } from "@/lib/chat/createNewRoom"; -import insertMemories from "@/lib/supabase/memories/insertMemories"; -import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; -import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { setupConversation } from "../setupConversation"; - const mockGenerateUUID = vi.mocked(generateUUID); const mockCreateNewRoom = vi.mocked(createNewRoom); const mockInsertMemories = vi.mocked(insertMemories); diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index c2d36bb..0bb9fc3 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatRequestBody } from "../validateChatRequest"; +// Import after mocks +import { setupToolsForRequest } from "../setupToolsForRequest"; +import { getMcpTools } from "@/lib/mcp/getMcpTools"; +import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent"; + // Mock external dependencies vi.mock("@/lib/mcp/getMcpTools", () => ({ getMcpTools: vi.fn(), @@ -10,11 +15,6 @@ vi.mock("@/lib/agents/googleSheetsAgent", () => ({ getGoogleSheetsTools: vi.fn(), })); -// Import after mocks -import { setupToolsForRequest } from "../setupToolsForRequest"; -import { getMcpTools } from "@/lib/mcp/getMcpTools"; -import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent"; - const mockGetMcpTools = vi.mocked(getMcpTools); const mockGetGoogleSheetsTools = vi.mocked(getGoogleSheetsTools); diff --git a/lib/chat/__tests__/types.test.ts b/lib/chat/__tests__/types.test.ts index 0cb89a5..36b8e7b 100644 --- a/lib/chat/__tests__/types.test.ts +++ b/lib/chat/__tests__/types.test.ts @@ -116,7 +116,7 @@ describe("Chat Types", () => { messages: [], experimental_generateMessageId: () => "test-id", tools: {}, - prepareStep: (options) => options, + prepareStep: options => options, }; expect(config.prepareStep).toBeDefined(); }); diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index 29f704e..59f7c8d 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -2,6 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextResponse } from "next/server"; import { validateChatRequest, chatRequestSchema } from "../validateChatRequest"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; + // Mock dependencies vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), @@ -43,16 +53,6 @@ vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ default: vi.fn((msg: unknown) => msg), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; -import { generateUUID } from "@/lib/uuid/generateUUID"; -import { createNewRoom } from "@/lib/chat/createNewRoom"; -import insertMemories from "@/lib/supabase/memories/insertMemories"; -import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; - const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); @@ -64,6 +64,11 @@ const mockInsertMemories = vi.mocked(insertMemories); const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); // Helper to create mock NextRequest +/** + * + * @param body + * @param headers + */ function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), @@ -83,10 +88,7 @@ describe("validateChatRequest", () => { it("rejects when neither messages nor prompt is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "test-key" }, - ); + const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); const result = await validateChatRequest(request as any); @@ -132,10 +134,7 @@ describe("validateChatRequest", () => { it("accepts valid prompt string", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "test-key" }, - ); + const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "test-key" }); const result = await validateChatRequest(request as any); @@ -172,16 +171,10 @@ describe("validateChatRequest", () => { it("rejects request with invalid API key", async () => { mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "Invalid API key" }, - { status: 401 }, - ), + NextResponse.json({ status: "error", message: "Invalid API key" }, { status: 401 }), ); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "invalid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "invalid-key" }); const result = await validateChatRequest(request as any); @@ -191,10 +184,7 @@ describe("validateChatRequest", () => { it("uses accountId from valid API key", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-abc-123"); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); const result = await validateChatRequest(request as any); @@ -243,10 +233,7 @@ describe("validateChatRequest", () => { orgId: "org-account-123", }); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "org-api-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); const result = await validateChatRequest(request as any); @@ -262,10 +249,7 @@ describe("validateChatRequest", () => { orgId: null, }); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "personal-api-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "personal-api-key" }); const result = await validateChatRequest(request as any); @@ -339,10 +323,7 @@ describe("validateChatRequest", () => { it("converts prompt to messages array", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "test-key" }, - ); + const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "test-key" }); const result = await validateChatRequest(request as any); @@ -546,10 +527,7 @@ describe("validateChatRequest", () => { orgId: "api-key-org-123", }); - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "org-api-key" }, - ); + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); const result = await validateChatRequest(request as any); @@ -595,7 +573,10 @@ describe("validateChatRequest", () => { mockGenerateUUID.mockReturnValue("generated-uuid-789"); mockCreateNewRoom.mockResolvedValue(undefined); - const request = createMockRequest({ prompt: "Create a new room" }, { "x-api-key": "test-key" }); + const request = createMockRequest( + { prompt: "Create a new room" }, + { "x-api-key": "test-key" }, + ); const result = await validateChatRequest(request as any); diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts index d3d6b74..42305cc 100644 --- a/lib/chat/handleChatCompletion.ts +++ b/lib/chat/handleChatCompletion.ts @@ -47,8 +47,7 @@ export async function handleChatCompletion( // Create room and send notification if this is a new conversation if (!room) { - const latestMessageText = - lastMessage.parts.find((part) => part.type === "text")?.text || ""; + const latestMessageText = lastMessage.parts.find(part => part.type === "text")?.text || ""; const conversationName = await generateChatTitle(latestMessageText); await Promise.all([ diff --git a/lib/chat/handleChatStream.ts b/lib/chat/handleChatStream.ts index fe97137..b181bcd 100644 --- a/lib/chat/handleChatStream.ts +++ b/lib/chat/handleChatStream.ts @@ -31,25 +31,23 @@ export async function handleChatStream(request: NextRequest): Promise const stream = createUIMessageStream({ originalMessages: body.messages, generateId: generateUUID, - execute: async (options) => { + execute: async options => { const { writer } = options; const result = await agent.stream(chatConfig); writer.merge(result.toUIMessageStream()); // Note: Credit handling and chat completion handling will be added // as part of the handleChatCredits and handleChatCompletion migrations }, - onFinish: async (event) => { + onFinish: async event => { if (event.isAborted) { return; } - const assistantMessages = event.messages.filter( - (message) => message.role === "assistant", - ); + const assistantMessages = event.messages.filter(message => message.role === "assistant"); const responseMessages = assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; await handleChatCompletion(body, responseMessages); }, - onError: (e) => { + onError: e => { console.error("/api/chat onError:", e); return JSON.stringify({ status: "error", diff --git a/lib/chat/setupChatRequest.ts b/lib/chat/setupChatRequest.ts index 31cef00..d21bf0a 100644 --- a/lib/chat/setupChatRequest.ts +++ b/lib/chat/setupChatRequest.ts @@ -38,7 +38,7 @@ export async function setupChatRequest(body: ChatRequestBody): Promise { + prepareStep: options => { const next = getPrepareStepResult(options); if (next) { return { ...options, ...next }; diff --git a/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts b/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts index 3f1ace6..df582c0 100644 --- a/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts +++ b/lib/chat/toolChains/__tests__/getPrepareStepResult.test.ts @@ -1,13 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import getPrepareStepResult from "../getPrepareStepResult"; + // Mock the toolChains module vi.mock("../toolChains", () => ({ TOOL_CHAINS: { - test_trigger: [ - { toolName: "step_one" }, - { toolName: "step_two" }, - { toolName: "step_three" }, - ], + test_trigger: [{ toolName: "step_one" }, { toolName: "step_two" }, { toolName: "step_three" }], custom_chain: [ { toolName: "custom_step_one", @@ -25,8 +23,6 @@ vi.mock("../toolChains", () => ({ }, })); -import getPrepareStepResult from "../getPrepareStepResult"; - describe("getPrepareStepResult", () => { beforeEach(() => { vi.clearAllMocks(); @@ -50,7 +46,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "unrelated_tool", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "unrelated_tool", + output: { type: "json", value: {} }, + }, ], }, ], @@ -70,7 +70,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, ], @@ -90,7 +94,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, { @@ -116,7 +124,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, { @@ -149,12 +161,20 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, { toolResults: [ - { toolCallId: "call-2", toolName: "some_other_tool", output: { type: "json", value: {} } }, + { + toolCallId: "call-2", + toolName: "some_other_tool", + output: { type: "json", value: {} }, + }, ], }, { @@ -183,7 +203,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "custom_chain", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "custom_chain", + output: { type: "json", value: {} }, + }, ], }, ], @@ -206,12 +230,20 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "custom_chain", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "custom_chain", + output: { type: "json", value: {} }, + }, ], }, { toolResults: [ - { toolCallId: "call-2", toolName: "custom_step_one", output: { type: "json", value: {} } }, + { + toolCallId: "call-2", + toolName: "custom_step_one", + output: { type: "json", value: {} }, + }, ], }, ], @@ -237,7 +269,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, { @@ -260,7 +296,11 @@ describe("getPrepareStepResult", () => { steps: [ { toolResults: [ - { toolCallId: "call-1", toolName: "test_trigger", output: { type: "json", value: {} } }, + { + toolCallId: "call-1", + toolName: "test_trigger", + output: { type: "json", value: {} }, + }, ], }, ], diff --git a/lib/chat/toolChains/__tests__/toolChains.test.ts b/lib/chat/toolChains/__tests__/toolChains.test.ts index 67f5822..d01a96e 100644 --- a/lib/chat/toolChains/__tests__/toolChains.test.ts +++ b/lib/chat/toolChains/__tests__/toolChains.test.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - TOOL_CHAINS, - TOOL_MODEL_MAP, - ToolChainItem, - PrepareStepResult, -} from "../toolChains"; +import { TOOL_CHAINS, TOOL_MODEL_MAP, ToolChainItem, PrepareStepResult } from "../toolChains"; describe("toolChains", () => { describe("ToolChainItem type", () => { @@ -86,7 +81,7 @@ describe("toolChains", () => { it("includes update_account_info with custom system prompt", () => { const chain = TOOL_CHAINS.create_new_artist; const updateAccountStep = chain.find( - (item) => item.toolName === "update_account_info" && item.system + item => item.toolName === "update_account_info" && item.system, ); expect(updateAccountStep).toBeDefined(); expect(updateAccountStep?.system).toContain("get_spotify_search"); @@ -112,9 +107,7 @@ describe("toolChains", () => { it("includes generate_txt_file with custom system prompt", () => { const chain = TOOL_CHAINS.create_release_report; - const generateStep = chain.find( - (item) => item.toolName === "generate_txt_file" - ); + const generateStep = chain.find(item => item.toolName === "generate_txt_file"); expect(generateStep).toBeDefined(); expect(generateStep?.system).toBeDefined(); }); diff --git a/lib/chat/toolChains/getExecutedToolTimeline.ts b/lib/chat/toolChains/getExecutedToolTimeline.ts index 1e522d7..32bf281 100644 --- a/lib/chat/toolChains/getExecutedToolTimeline.ts +++ b/lib/chat/toolChains/getExecutedToolTimeline.ts @@ -10,16 +10,16 @@ type ToolCallContent = { const getExecutedToolTimeline = (steps: StepResult[]): string[] => { const toolCallsContent = steps.flatMap( (step): ToolCallContent[] => - step.toolResults?.map((result) => ({ + step.toolResults?.map(result => ({ type: "tool-result" as const, toolCallId: result.toolCallId || "", toolName: result.toolName, output: { type: "json" as const, value: result.output }, - })) || [] + })) || [], ); // Build timeline of executed tools from toolCallsContent - return toolCallsContent.map((call) => call.toolName); + return toolCallsContent.map(call => call.toolName); }; export default getExecutedToolTimeline; diff --git a/lib/chat/toolChains/getPrepareStepResult.ts b/lib/chat/toolChains/getPrepareStepResult.ts index 5a908af..c011c07 100644 --- a/lib/chat/toolChains/getPrepareStepResult.ts +++ b/lib/chat/toolChains/getPrepareStepResult.ts @@ -12,10 +12,10 @@ type PrepareStepOptions = { /** * Returns the next tool to run based on timeline progression through tool chains. * Uses toolCallsContent to track exact execution order and position in sequence. + * + * @param options */ -const getPrepareStepResult = ( - options: PrepareStepOptions -): PrepareStepResult | undefined => { +const getPrepareStepResult = (options: PrepareStepOptions): PrepareStepResult | undefined => { const { steps } = options; // Extract tool calls timeline (history) from steps const executedTimeline = getExecutedToolTimeline(steps); @@ -32,10 +32,7 @@ const getPrepareStepResult = ( let timelinePosition = triggerIndex; // Walk through the timeline starting from trigger - while ( - timelinePosition < executedTimeline.length && - sequencePosition < fullSequence.length - ) { + while (timelinePosition < executedTimeline.length && sequencePosition < fullSequence.length) { const currentTool = executedTimeline[timelinePosition]; const expectedTool = fullSequence[sequencePosition].toolName; diff --git a/lib/chat/toolChains/index.ts b/lib/chat/toolChains/index.ts index 7d52b25..24bc4a8 100644 --- a/lib/chat/toolChains/index.ts +++ b/lib/chat/toolChains/index.ts @@ -1,4 +1,9 @@ -export { TOOL_CHAINS, TOOL_MODEL_MAP, type ToolChainItem, type PrepareStepResult } from "./toolChains"; +export { + TOOL_CHAINS, + TOOL_MODEL_MAP, + type ToolChainItem, + type PrepareStepResult, +} from "./toolChains"; export { default as getPrepareStepResult } from "./getPrepareStepResult"; export { default as getExecutedToolTimeline } from "./getExecutedToolTimeline"; export { createNewArtistToolChain } from "./createNewArtistToolChain"; diff --git a/lib/chat/types.ts b/lib/chat/types.ts index 2fe93bf..42756f6 100644 --- a/lib/chat/types.ts +++ b/lib/chat/types.ts @@ -22,7 +22,7 @@ export interface ChatConfig extends RoutingDecision { messages: ModelMessage[]; experimental_generateMessageId: () => string; experimental_download?: ( - files: Array<{ url: URL; isUrlSupportedByModel: boolean }> + files: Array<{ url: URL; isUrlSupportedByModel: boolean }>, ) => Promise>; tools: ToolSet; prepareStep?: PrepareStepFunction; diff --git a/lib/chat/validateMessages.ts b/lib/chat/validateMessages.ts index 7ae4c8e..e67aec8 100644 --- a/lib/chat/validateMessages.ts +++ b/lib/chat/validateMessages.ts @@ -14,8 +14,6 @@ export function validateMessages(messages: UIMessage[]) { return { lastMessage: messages[messages.length - 1], - validMessages: messages.filter( - (m) => m.parts.find((part) => part.type === "text")?.text?.length, - ), + validMessages: messages.filter(m => m.parts.find(part => part.type === "text")?.text?.length), }; } diff --git a/lib/chats/__tests__/generateChatTitle.test.ts b/lib/chats/__tests__/generateChatTitle.test.ts index 32be1dd..e1bb8a8 100644 --- a/lib/chats/__tests__/generateChatTitle.test.ts +++ b/lib/chats/__tests__/generateChatTitle.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateChatTitle } from "../generateChatTitle"; +import generateText from "@/lib/ai/generateText"; + vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn(), })); -import { generateChatTitle } from "../generateChatTitle"; -import generateText from "@/lib/ai/generateText"; - const mockGenerateText = vi.mocked(generateText); describe("generateChatTitle", () => { diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts index e233e44..0ec4fbe 100644 --- a/lib/chats/__tests__/validateCreateChatBody.test.ts +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect } from "vitest"; import { NextResponse } from "next/server"; -import { - validateCreateChatBody, - createChatBodySchema, -} from "../validateCreateChatBody"; +import { validateCreateChatBody, createChatBodySchema } from "../validateCreateChatBody"; describe("validateCreateChatBody", () => { describe("artistId validation", () => { @@ -13,9 +10,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).artistId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).artistId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for artistId", () => { @@ -40,9 +35,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).chatId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).chatId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for chatId", () => { @@ -61,9 +54,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).accountId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).accountId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for accountId", () => { @@ -95,9 +86,7 @@ describe("validateCreateChatBody", () => { 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.data.accountId).toBe("123e4567-e89b-12d3-a456-426614174002"); } }); }); @@ -110,9 +99,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).firstMessage).toBe( - "What marketing strategies should I use?", - ); + expect((result as any).firstMessage).toBe("What marketing strategies should I use?"); }); it("accepts missing firstMessage (optional)", () => { diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index 23a6113..5a48471 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -27,8 +27,7 @@ export async function authorizeConnector( ): Promise { const composio = getComposioClient(); - const callbackUrl = - customCallbackUrl || getCallbackUrl({ destination: "connectors" }); + const callbackUrl = customCallbackUrl || getCallbackUrl({ destination: "connectors" }); const session = await composio.create(userId, { manageConnections: { diff --git a/lib/composio/connectors/disconnectConnector.ts b/lib/composio/connectors/disconnectConnector.ts index 3aff9b9..e20b2dd 100644 --- a/lib/composio/connectors/disconnectConnector.ts +++ b/lib/composio/connectors/disconnectConnector.ts @@ -27,9 +27,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/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index 6f5ac12..3aa41c4 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -21,7 +21,7 @@ export async function getConnectors(userId: string): Promise { const session = await composio.create(userId); const toolkits = await session.toolkits(); - return toolkits.items.map((toolkit) => ({ + return toolkits.items.map(toolkit => ({ slug: toolkit.slug, name: toolkit.name, isConnected: toolkit.connection?.isActive ?? false, diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index df3570e..b6b21a3 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -9,9 +9,7 @@ export const authorizeConnectorBodySchema = z.object({ callback_url: z.string().url("callback_url must be a valid URL").optional(), }); -export type AuthorizeConnectorBody = z.infer< - typeof authorizeConnectorBodySchema ->; +export type AuthorizeConnectorBody = z.infer; /** * Validates request body for POST /api/connectors/authorize. @@ -20,7 +18,7 @@ export type AuthorizeConnectorBody = z.infer< * @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 +31,7 @@ export function validateAuthorizeConnectorBody( { status: 400, headers: getCorsHeaders(), - } + }, ); } diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index 70dfb15..84d24c8 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -14,7 +14,9 @@ export type DisconnectConnectorBody = z.infer { 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 570c925..8c83505 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -19,6 +19,7 @@ interface CallbackOptions { * * @param options.destination - Where to redirect: "chat" or "connectors" * @param options.roomId - For chat destination, the room ID to return to + * @param options * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts index 1cd36e4..e417c2b 100644 --- a/lib/composio/toolRouter/createSession.ts +++ b/lib/composio/toolRouter/createSession.ts @@ -9,6 +9,9 @@ const ENABLED_TOOLKITS = ["googlesheets"]; /** * Create a Composio Tool Router session for a user. + * + * @param userId + * @param roomId */ export async function createToolRouterSession(userId: string, roomId?: string) { const composio = getComposioClient(); diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 0140dd5..d65e572 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -50,10 +50,7 @@ function isValidTool(tool: unknown): tool is Tool { * @param roomId - Optional chat room ID for OAuth redirect * @returns ToolSet containing filtered Vercel AI SDK tools */ -export async function getComposioTools( - userId: string, - roomId?: string -): Promise { +export async function getComposioTools(userId: string, roomId?: string): Promise { const session = await createToolRouterSession(userId, roomId); const allTools = await session.tools(); diff --git a/lib/contact/contactTeam.ts b/lib/contact/contactTeam.ts index e10af73..f6880ad 100644 --- a/lib/contact/contactTeam.ts +++ b/lib/contact/contactTeam.ts @@ -41,4 +41,3 @@ ${message}`; }; } } - diff --git a/lib/credits/__tests__/getCreditUsage.test.ts b/lib/credits/__tests__/getCreditUsage.test.ts index fa55b85..8aa2919 100644 --- a/lib/credits/__tests__/getCreditUsage.test.ts +++ b/lib/credits/__tests__/getCreditUsage.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getModel } from "@/lib/ai/getModel"; +import { getCreditUsage } from "../getCreditUsage"; + vi.mock("@/lib/ai/getModel", () => ({ getModel: vi.fn(), })); -import { getModel } from "@/lib/ai/getModel"; -import { getCreditUsage } from "../getCreditUsage"; - const mockGetModel = vi.mocked(getModel); describe("getCreditUsage", () => { diff --git a/lib/credits/__tests__/handleChatCredits.test.ts b/lib/credits/__tests__/handleChatCredits.test.ts index 27f6a9a..500b45b 100644 --- a/lib/credits/__tests__/handleChatCredits.test.ts +++ b/lib/credits/__tests__/handleChatCredits.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getCreditUsage } from "@/lib/credits/getCreditUsage"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { handleChatCredits } from "../handleChatCredits"; + vi.mock("@/lib/credits/getCreditUsage", () => ({ getCreditUsage: vi.fn(), })); @@ -8,10 +12,6 @@ vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), })); -import { getCreditUsage } from "@/lib/credits/getCreditUsage"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { handleChatCredits } from "../handleChatCredits"; - const mockGetCreditUsage = vi.mocked(getCreditUsage); const mockDeductCredits = vi.mocked(deductCredits); @@ -143,10 +143,7 @@ describe("handleChatCredits", () => { accountId: "account-123", }); - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to handle chat credits:", - expect.any(Error), - ); + expect(consoleSpy).toHaveBeenCalledWith("Failed to handle chat credits:", expect.any(Error)); }); }); }); diff --git a/lib/credits/getCreditUsage.ts b/lib/credits/getCreditUsage.ts index 846314c..a74d104 100644 --- a/lib/credits/getCreditUsage.ts +++ b/lib/credits/getCreditUsage.ts @@ -3,6 +3,7 @@ import { LanguageModelUsage } from "ai"; /** * Calculates the total spend in USD for a given language model usage. + * * @param usage - The language model usage data * @param modelId - The ID of the model used * @returns The total spend in USD or 0 if calculation fails @@ -20,10 +21,8 @@ export const getCreditUsage = async ( // LanguageModelUsage uses inputTokens/outputTokens (SDK v3) // or promptTokens/completionTokens (SDK v2 compatibility) - const inputTokens = - (usage as any).inputTokens ?? (usage as any).promptTokens; - const outputTokens = - (usage as any).outputTokens ?? (usage as any).completionTokens; + const inputTokens = (usage as any).inputTokens ?? (usage as any).promptTokens; + const outputTokens = (usage as any).outputTokens ?? (usage as any).completionTokens; if (!inputTokens || !outputTokens) { console.error("No tokens found in usage"); diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts index c0462ea..3058757 100644 --- a/lib/credits/handleChatCredits.ts +++ b/lib/credits/handleChatCredits.ts @@ -11,9 +11,13 @@ interface HandleChatCreditsParams { /** * Handles credit deduction after chat completion. * Calculates usage cost and deducts appropriate credits from the user's account. + * + * @param usage.usage * @param usage - The language model usage data * @param model - The model ID used for the chat * @param accountId - The account ID to deduct credits from (optional) + * @param usage.model + * @param usage.accountId */ export const handleChatCredits = async ({ usage, diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts index 5fcf968..d7aebee 100644 --- a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts +++ b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts @@ -104,8 +104,7 @@ describe("extractRoomIdFromHtml", () => { }); it("returns undefined for invalid UUID format in link", () => { - const html = - 'link'; + const html = 'link'; const result = extractRoomIdFromHtml(html); diff --git a/lib/emails/inbound/__tests__/getFromWithName.test.ts b/lib/emails/inbound/__tests__/getFromWithName.test.ts index bb4efd9..3b0c867 100644 --- a/lib/emails/inbound/__tests__/getFromWithName.test.ts +++ b/lib/emails/inbound/__tests__/getFromWithName.test.ts @@ -34,10 +34,7 @@ describe("getFromWithName", () => { }); it("falls back to cc array when not in to array", () => { - const result = getFromWithName( - ["other@example.com"], - ["support@mail.recoupable.com"], - ); + const result = getFromWithName(["other@example.com"], ["support@mail.recoupable.com"]); expect(result).toBe("Support by Recoup "); }); @@ -66,9 +63,7 @@ describe("getFromWithName", () => { }); it("throws error when arrays are empty", () => { - expect(() => getFromWithName([])).toThrow( - "No email found ending with @mail.recoupable.com", - ); + expect(() => getFromWithName([])).toThrow("No email found ending with @mail.recoupable.com"); }); }); diff --git a/lib/emails/inbound/extractRoomIdFromHtml.ts b/lib/emails/inbound/extractRoomIdFromHtml.ts index f637b17..6a48f95 100644 --- a/lib/emails/inbound/extractRoomIdFromHtml.ts +++ b/lib/emails/inbound/extractRoomIdFromHtml.ts @@ -10,10 +10,7 @@ const CHAT_LINK_PATTERNS = [ // Pattern to find UUID after /chat/ or %2Fchat%2F in link text that may contain tags // The link text version: "https:///chat.recoupable.com/chat/uuid" -const WBR_STRIPPED_PATTERN = new RegExp( - `chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, - "i", -); +const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"); /** * Extracts the roomId from email HTML by looking for a Recoup chat link. diff --git a/lib/files/__tests__/getKnowledgeBaseText.test.ts b/lib/files/__tests__/getKnowledgeBaseText.test.ts index 5dbb958..e9815e9 100644 --- a/lib/files/__tests__/getKnowledgeBaseText.test.ts +++ b/lib/files/__tests__/getKnowledgeBaseText.test.ts @@ -47,7 +47,9 @@ describe("getKnowledgeBaseText", () => { text: () => Promise.resolve("Plain text content"), } as Response); - const knowledges = [{ name: "notes.txt", url: "https://example.com/notes.txt", type: "text/plain" }]; + const knowledges = [ + { name: "notes.txt", url: "https://example.com/notes.txt", type: "text/plain" }, + ]; const result = await getKnowledgeBaseText(knowledges); expect(result).toContain("Plain text content"); diff --git a/lib/files/generateAndStoreTxtFile.ts b/lib/files/generateAndStoreTxtFile.ts index 331505d..c5b35cd 100644 --- a/lib/files/generateAndStoreTxtFile.ts +++ b/lib/files/generateAndStoreTxtFile.ts @@ -65,4 +65,3 @@ export async function generateAndStoreTxtFile(contents: string): Promise file.data && file.mediaType); } - diff --git a/lib/mcp/__tests__/getMcpTools.test.ts b/lib/mcp/__tests__/getMcpTools.test.ts index 0422fbc..860c575 100644 --- a/lib/mcp/__tests__/getMcpTools.test.ts +++ b/lib/mcp/__tests__/getMcpTools.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getMcpTools } from "../getMcpTools"; +import { experimental_createMCPClient } from "@ai-sdk/mcp"; + vi.mock("@ai-sdk/mcp", () => ({ experimental_createMCPClient: vi.fn(), })); @@ -8,9 +11,6 @@ vi.mock("@/lib/networking/getBaseUrl", () => ({ getBaseUrl: vi.fn().mockReturnValue("https://test.vercel.app"), })); -import { getMcpTools } from "../getMcpTools"; -import { experimental_createMCPClient } from "@ai-sdk/mcp"; - const mockCreateMCPClient = vi.mocked(experimental_createMCPClient); describe("getMcpTools", () => { diff --git a/lib/mcp/getAccountIdByApiKey.ts b/lib/mcp/getAccountIdByApiKey.ts index 17e1578..8ba55d4 100644 --- a/lib/mcp/getAccountIdByApiKey.ts +++ b/lib/mcp/getAccountIdByApiKey.ts @@ -8,9 +8,7 @@ import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAcco * @param apiKey - The raw API key to validate. * @returns The account ID if valid, or null if invalid. */ -export async function getAccountIdByApiKey( - apiKey: string, -): Promise { +export async function getAccountIdByApiKey(apiKey: string): Promise { const keyHash = hashApiKey(apiKey, PRIVY_PROJECT_SECRET); const apiKeys = await selectAccountApiKeys({ keyHash }); diff --git a/lib/mcp/resolveAccountId.ts b/lib/mcp/resolveAccountId.ts index 100dcaf..1e0e7bc 100644 --- a/lib/mcp/resolveAccountId.ts +++ b/lib/mcp/resolveAccountId.ts @@ -16,6 +16,8 @@ export interface ResolveAccountIdResult { * Validates access when an org API key attempts to use an account_id override. * * @param params - The auth info and optional account_id override. + * @param params.authInfo + * @param params.accountIdOverride * @returns The resolved accountId or an error message. */ export async function resolveAccountId({ diff --git a/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts index 84c5344..ece89c6 100644 --- a/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts +++ b/lib/mcp/tools/__tests__/registerSendEmailTool.test.ts @@ -85,7 +85,10 @@ describe("registerSendEmailTool", () => { }); it("returns error when sendEmailWithResend returns NextResponse", async () => { - const errorResponse = NextResponse.json({ error: { message: "Rate limited" } }, { status: 429 }); + const errorResponse = NextResponse.json( + { error: { message: "Rate limited" } }, + { status: 429 }, + ); mockSendEmailWithResend.mockResolvedValue(errorResponse); const result = await registeredHandler({ diff --git a/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts b/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts index 64ee276..2474e30 100644 --- a/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts +++ b/lib/mcp/tools/__tests__/registerWebDeepResearchTool.test.ts @@ -115,10 +115,7 @@ describe("registerWebDeepResearchTool", () => { messages: [{ role: "user", content: "Research this topic" }], }); - expect(mockChatWithPerplexity).toHaveBeenCalledWith( - expect.any(Array), - "sonar-deep-research", - ); + expect(mockChatWithPerplexity).toHaveBeenCalledWith(expect.any(Array), "sonar-deep-research"); }); it("handles no citations gracefully", async () => { diff --git a/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts b/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts index 54d216e..4fe6b10 100644 --- a/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts +++ b/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts @@ -26,4 +26,3 @@ export function registerGetArtistSocialsTool(server: McpServer): void { }, ); } - diff --git a/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts b/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts index 898e1dc..864b0f6 100644 --- a/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts +++ b/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts @@ -55,12 +55,9 @@ export function registerUpdateArtistSocialsTool(server: McpServer): void { } catch (error) { console.error("Error updating artist socials:", error); const errorMessage = - error instanceof Error - ? error.message - : "Failed to update artist socials."; + error instanceof Error ? error.message : "Failed to update artist socials."; return getToolResultError(errorMessage); } }, ); } - diff --git a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts index 42056f7..d438bdd 100644 --- a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -3,6 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; + const mockCreateArtistInDb = vi.fn(); const mockCopyRoom = vi.fn(); const mockCanAccessAccount = vi.fn(); @@ -19,12 +21,14 @@ vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), })); -import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; - type ServerRequestHandlerExtra = RequestHandlerExtra; /** * Creates a mock extra object with optional authInfo. + * + * @param authInfo + * @param authInfo.accountId + * @param authInfo.orgId */ function createMockExtra(authInfo?: { accountId?: string; diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index cad1780..7495bcb 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -4,10 +4,7 @@ import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sd import { z } from "zod"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import { - createArtistInDb, - type CreateArtistResult, -} from "@/lib/artists/createArtistInDb"; +import { createArtistInDb, type CreateArtistResult } from "@/lib/artists/createArtistInDb"; import { copyRoom } from "@/lib/rooms/copyRoom"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; @@ -69,7 +66,10 @@ export function registerCreateNewArtistTool(server: McpServer): void { "The organization_id parameter is optional — use the organization_id from the system prompt context to link the artist to the user's selected organization.", inputSchema: createNewArtistSchema, }, - async (args: CreateNewArtistArgs, extra: RequestHandlerExtra) => { + async ( + args: CreateNewArtistArgs, + extra: RequestHandlerExtra, + ) => { try { const { name, account_id, active_conversation_id, organization_id } = args; diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 8c6bec3..f917783 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -13,6 +13,10 @@ const composioSchema = z.object({ type ComposioArgs = z.infer; +/** + * + * @param server + */ export function registerComposioTools(server: McpServer): void { server.registerTool( "composio", @@ -33,6 +37,6 @@ export function registerComposioTools(server: McpServer): void { const tools = await getComposioTools(accountId, args.room_id); return getCallToolResult(JSON.stringify(tools)); - } + }, ); } diff --git a/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts index b086155..cfe1828 100644 --- a/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts +++ b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts @@ -52,4 +52,3 @@ export function registerCreateKnowledgeBaseTool(server: McpServer): void { }, ); } - diff --git a/lib/mcp/tools/registerWebDeepResearchTool.ts b/lib/mcp/tools/registerWebDeepResearchTool.ts index df9f5d8..ae84f49 100644 --- a/lib/mcp/tools/registerWebDeepResearchTool.ts +++ b/lib/mcp/tools/registerWebDeepResearchTool.ts @@ -58,7 +58,9 @@ export function registerWebDeepResearchTool(server: McpServer): void { return getToolResultSuccess(finalContent); } catch (error) { return getToolResultError( - error instanceof Error ? `Deep research failed: ${error.message}` : "Deep research failed", + error instanceof Error + ? `Deep research failed: ${error.message}` + : "Deep research failed", ); } }, diff --git a/lib/mcp/tools/transcribe/index.ts b/lib/mcp/tools/transcribe/index.ts index 01ff8e1..dedbb17 100644 --- a/lib/mcp/tools/transcribe/index.ts +++ b/lib/mcp/tools/transcribe/index.ts @@ -9,4 +9,3 @@ import { registerTranscribeAudioTool } from "./registerTranscribeAudioTool"; export function registerTranscribeTools(server: McpServer): void { registerTranscribeAudioTool(server); } - diff --git a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts index 0d78182..d8a64f7 100644 --- a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts +++ b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts @@ -15,6 +15,10 @@ const transcribeAudioSchema = z.object({ type TranscribeAudioArgs = z.infer; +/** + * + * @param server + */ export function registerTranscribeAudioTool(server: McpServer): void { server.registerTool( "transcribe_audio", @@ -48,4 +52,3 @@ export function registerTranscribeAudioTool(server: McpServer): void { }, ); } - diff --git a/lib/messages/__tests__/convertToUiMessages.test.ts b/lib/messages/__tests__/convertToUiMessages.test.ts index 7528006..a541043 100644 --- a/lib/messages/__tests__/convertToUiMessages.test.ts +++ b/lib/messages/__tests__/convertToUiMessages.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import convertToUiMessages from "../convertToUiMessages"; + // Mock generateUUID before importing the module vi.mock("@/lib/uuid/generateUUID", () => ({ default: vi.fn(() => "generated-uuid"), generateUUID: vi.fn(() => "generated-uuid"), })); -import convertToUiMessages from "../convertToUiMessages"; - describe("convertToUiMessages", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts b/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts index ccc125e..f1bbbd5 100644 --- a/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts +++ b/lib/messages/__tests__/extractImageUrlsFromMessages.test.ts @@ -10,9 +10,7 @@ describe("extractImageUrlsFromMessages", () => { }); it("returns empty array for messages without parts", () => { - const messages: UIMessage[] = [ - { id: "1", role: "user", content: "Hello" } as UIMessage, - ]; + const messages: UIMessage[] = [{ id: "1", role: "user", content: "Hello" } as UIMessage]; const result = extractImageUrlsFromMessages(messages); expect(result).toEqual([]); }); @@ -23,9 +21,7 @@ describe("extractImageUrlsFromMessages", () => { id: "1", role: "user", content: "Check this image", - parts: [ - { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }, - ], + parts: [{ type: "file", mediaType: "image/png", url: "https://example.com/image.png" }], } as UIMessage, ]; const result = extractImageUrlsFromMessages(messages); @@ -38,17 +34,13 @@ describe("extractImageUrlsFromMessages", () => { id: "1", role: "user", content: "Image 1", - parts: [ - { type: "file", mediaType: "image/png", url: "https://example.com/1.png" }, - ], + parts: [{ type: "file", mediaType: "image/png", url: "https://example.com/1.png" }], } as UIMessage, { id: "2", role: "user", content: "Image 2", - parts: [ - { type: "file", mediaType: "image/jpeg", url: "https://example.com/2.jpg" }, - ], + parts: [{ type: "file", mediaType: "image/jpeg", url: "https://example.com/2.jpg" }], } as UIMessage, ]; const result = extractImageUrlsFromMessages(messages); @@ -240,10 +232,7 @@ describe("extractImageUrlsFromMessages", () => { } as UIMessage, ]; const result = extractImageUrlsFromMessages(messages); - expect(result).toEqual([ - "https://example.com/valid.png", - "https://example.com/valid.gif", - ]); + expect(result).toEqual(["https://example.com/valid.png", "https://example.com/valid.gif"]); }); }); }); diff --git a/lib/messages/__tests__/getLatestUserMessageText.test.ts b/lib/messages/__tests__/getLatestUserMessageText.test.ts index 855cd33..f99dd7b 100644 --- a/lib/messages/__tests__/getLatestUserMessageText.test.ts +++ b/lib/messages/__tests__/getLatestUserMessageText.test.ts @@ -112,9 +112,7 @@ describe("getLatestUserMessageText", () => { id: "1", role: "user", content: "Hello", - parts: [ - { type: "file", mediaType: "image/png", url: "https://example.com/image.png" }, - ], + parts: [{ type: "file", mediaType: "image/png", url: "https://example.com/image.png" }], }, ]; diff --git a/lib/messages/__tests__/getTextContent.test.ts b/lib/messages/__tests__/getTextContent.test.ts index 9820e6a..1822bb4 100644 --- a/lib/messages/__tests__/getTextContent.test.ts +++ b/lib/messages/__tests__/getTextContent.test.ts @@ -40,9 +40,7 @@ describe("getTextContent", () => { }); it("returns empty string when no text parts exist", () => { - const content = [ - { type: "image" as const, image: "data:image/png;base64,..." }, - ] as any; + const content = [{ type: "image" as const, image: "data:image/png;base64,..." }] as any; expect(getTextContent(content)).toBe(""); }); }); diff --git a/lib/messages/convertToUiMessages.ts b/lib/messages/convertToUiMessages.ts index 7318f23..c517f39 100644 --- a/lib/messages/convertToUiMessages.ts +++ b/lib/messages/convertToUiMessages.ts @@ -23,7 +23,7 @@ type InputMessage = UIMessage | ModelMessage; * @returns Array of messages in UIMessage format */ export default function convertToUiMessages(messages: InputMessage[]): UIMessage[] { - return messages.map((message) => { + return messages.map(message => { if (isUiMessage(message)) { return message; } diff --git a/lib/messages/getLatestUserMessageText.ts b/lib/messages/getLatestUserMessageText.ts index 2d83f50..9433ffb 100644 --- a/lib/messages/getLatestUserMessageText.ts +++ b/lib/messages/getLatestUserMessageText.ts @@ -7,7 +7,7 @@ import { UIMessage } from "ai"; * @returns The text content of the latest user message, or empty string if none found */ export default function getLatestUserMessageText(messages: UIMessage[]): string { - const userMessages = messages.filter((msg) => msg.role === "user"); + const userMessages = messages.filter(msg => msg.role === "user"); const latestUserMessage = userMessages[userMessages.length - 1]; - return latestUserMessage?.parts?.find((part) => part.type === "text")?.text || ""; + return latestUserMessage?.parts?.find(part => part.type === "text")?.text || ""; } diff --git a/lib/messages/getTextContent.ts b/lib/messages/getTextContent.ts index 8f54bd1..8423c15 100644 --- a/lib/messages/getTextContent.ts +++ b/lib/messages/getTextContent.ts @@ -13,6 +13,6 @@ export default function getTextContent(content: ModelMessage["content"]): string // Content is an array of parts - extract and join text parts return content .filter((part): part is { type: "text"; text: string } => part.type === "text") - .map((part) => part.text) + .map(part => part.text) .join(""); } diff --git a/lib/organizations/__tests__/canAccessAccount.test.ts b/lib/organizations/__tests__/canAccessAccount.test.ts index 54f5a23..b7d9304 100644 --- a/lib/organizations/__tests__/canAccessAccount.test.ts +++ b/lib/organizations/__tests__/canAccessAccount.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { canAccessAccount } from "../canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + // Mock RECOUP_ORG_ID constant vi.mock("@/lib/const", () => ({ RECOUP_ORG_ID: "recoup-admin-org-id", @@ -11,8 +13,6 @@ vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => getAccountOrganizations: vi.fn(), })); -import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; - describe("canAccessAccount", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/organizations/__tests__/validateOrganizationAccess.test.ts b/lib/organizations/__tests__/validateOrganizationAccess.test.ts index 27f26fd..2182d5f 100644 --- a/lib/organizations/__tests__/validateOrganizationAccess.test.ts +++ b/lib/organizations/__tests__/validateOrganizationAccess.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateOrganizationAccess } from "../validateOrganizationAccess"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + // Mock getAccountOrganizations supabase lib vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ getAccountOrganizations: vi.fn(), })); -import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; - const mockGetAccountOrganizations = vi.mocked(getAccountOrganizations); describe("validateOrganizationAccess", () => { diff --git a/lib/organizations/addArtistToOrgHandler.ts b/lib/organizations/addArtistToOrgHandler.ts index c422bcd..561cd9c 100644 --- a/lib/organizations/addArtistToOrgHandler.ts +++ b/lib/organizations/addArtistToOrgHandler.ts @@ -62,4 +62,3 @@ export async function addArtistToOrgHandler(request: NextRequest): Promise { +export async function canAccessAccount(params: CanAccessAccountParams): Promise { const { orgId, targetAccountId } = params; if (!orgId || !targetAccountId) { diff --git a/lib/organizations/createOrganizationHandler.ts b/lib/organizations/createOrganizationHandler.ts index 92fc2b2..8154890 100644 --- a/lib/organizations/createOrganizationHandler.ts +++ b/lib/organizations/createOrganizationHandler.ts @@ -61,4 +61,3 @@ export async function createOrganizationHandler(request: NextRequest): Promise(); return rawOrgs @@ -37,4 +39,3 @@ export function formatAccountOrganizations(rawOrgs: AccountOrganization[]): Form organization_image: org.organization?.account_info?.[0]?.image || null, })); } - diff --git a/lib/organizations/validateAddArtistToOrgBody.ts b/lib/organizations/validateAddArtistToOrgBody.ts index 6b57dc1..42cd49c 100644 --- a/lib/organizations/validateAddArtistToOrgBody.ts +++ b/lib/organizations/validateAddArtistToOrgBody.ts @@ -4,7 +4,9 @@ import { z } from "zod"; export const addArtistToOrgBodySchema = z.object({ artistId: z.string({ message: "artistId is required" }).uuid("artistId must be a valid UUID"), - organizationId: z.string({ message: "organizationId is required" }).uuid("organizationId must be a valid UUID"), + organizationId: z + .string({ message: "organizationId is required" }) + .uuid("organizationId must be a valid UUID"), }); export type AddArtistToOrgBody = z.infer; @@ -35,4 +37,3 @@ export function validateAddArtistToOrgBody(body: unknown): NextResponse | AddArt return result.data; } - diff --git a/lib/organizations/validateCreateOrganizationBody.ts b/lib/organizations/validateCreateOrganizationBody.ts index 1ae1f48..5026b2a 100644 --- a/lib/organizations/validateCreateOrganizationBody.ts +++ b/lib/organizations/validateCreateOrganizationBody.ts @@ -37,4 +37,3 @@ export function validateCreateOrganizationBody( return result.data; } - diff --git a/lib/organizations/validateOrganizationsQuery.ts b/lib/organizations/validateOrganizationsQuery.ts index e7f93f8..4d58b15 100644 --- a/lib/organizations/validateOrganizationsQuery.ts +++ b/lib/organizations/validateOrganizationsQuery.ts @@ -37,4 +37,3 @@ export function validateOrganizationsQuery( return result.data; } - diff --git a/lib/perplexity/chatWithPerplexity.ts b/lib/perplexity/chatWithPerplexity.ts index 476d1c3..cf779d0 100644 --- a/lib/perplexity/chatWithPerplexity.ts +++ b/lib/perplexity/chatWithPerplexity.ts @@ -44,7 +44,9 @@ export async function chatWithPerplexity( if (!response.ok) { const errorText = await response.text(); - throw new Error(`Perplexity API error: ${response.status} ${response.statusText}\n${errorText}`); + throw new Error( + `Perplexity API error: ${response.status} ${response.statusText}\n${errorText}`, + ); } const data = await response.json(); diff --git a/lib/prompts/getSystemPrompt.ts b/lib/prompts/getSystemPrompt.ts index 5496467..5077609 100644 --- a/lib/prompts/getSystemPrompt.ts +++ b/lib/prompts/getSystemPrompt.ts @@ -13,6 +13,7 @@ import { AccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetail * @param params.artistInstruction - The artist instruction * @param params.conversationName - The name of the conversation * @param params.accountWithDetails - The account with details + * @param params.orgId * @returns The system prompt */ export function getSystemPrompt({ diff --git a/lib/rooms/__tests__/copyRoom.test.ts b/lib/rooms/__tests__/copyRoom.test.ts index f49326e..020ae92 100644 --- a/lib/rooms/__tests__/copyRoom.test.ts +++ b/lib/rooms/__tests__/copyRoom.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { copyRoom } from "../copyRoom"; + const mockSelectRoom = vi.fn(); const mockInsertRoom = vi.fn(); @@ -15,8 +17,6 @@ vi.mock("@/lib/uuid/generateUUID", () => ({ default: () => "generated-uuid-123", })); -import { copyRoom } from "../copyRoom"; - describe("copyRoom", () => { const mockSourceRoom = { id: "source-room-123", diff --git a/lib/rooms/copyRoom.ts b/lib/rooms/copyRoom.ts index f6f11be..4baaf16 100644 --- a/lib/rooms/copyRoom.ts +++ b/lib/rooms/copyRoom.ts @@ -10,10 +10,7 @@ import generateUUID from "@/lib/uuid/generateUUID"; * @param artistId - The ID of the artist for the new room * @returns The ID of the new room or null if operation failed */ -export async function copyRoom( - sourceRoomId: string, - artistId: string, -): Promise { +export async function copyRoom(sourceRoomId: string, artistId: string): Promise { try { // Get the source room data const sourceRoom = await selectRoom(sourceRoomId); diff --git a/lib/segments/createSegmentResponses.ts b/lib/segments/createSegmentResponses.ts index c9269ab..ec25b5d 100644 --- a/lib/segments/createSegmentResponses.ts +++ b/lib/segments/createSegmentResponses.ts @@ -27,4 +27,3 @@ export const errorResponse = (message: string) => ({ data: [], count: 0, }); - diff --git a/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts index 2087aed..725b889 100644 --- a/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts +++ b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { insertAccountArtistId } from "../insertAccountArtistId"; + const mockFrom = vi.fn(); const mockInsert = vi.fn(); const mockSelect = vi.fn(); @@ -11,8 +13,6 @@ vi.mock("@/lib/supabase/serverClient", () => ({ }, })); -import { insertAccountArtistId } from "../insertAccountArtistId"; - describe("insertAccountArtistId", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/supabase/account_artist_ids/getAccountArtistIds.ts b/lib/supabase/account_artist_ids/getAccountArtistIds.ts index 834c48a..42b550d 100644 --- a/lib/supabase/account_artist_ids/getAccountArtistIds.ts +++ b/lib/supabase/account_artist_ids/getAccountArtistIds.ts @@ -8,7 +8,9 @@ export type AccountArtistRow = ArtistQueryRow & { artist_id: string; pinned: boo * Get all artists for an array of artist IDs or account IDs, with full info. * Returns raw data - formatting should be done by caller. * - * @param params Object with artistIds or accountIds array + * @param params - Object with artistIds or accountIds array + * @param params.artistIds + * @param params.accountIds * @returns Array of raw artist rows from database */ export async function getAccountArtistIds(params: { @@ -46,4 +48,3 @@ export async function getAccountArtistIds(params: { return (data || []) as unknown as AccountArtistRow[]; } - diff --git a/lib/supabase/account_info/insertAccountInfo.ts b/lib/supabase/account_info/insertAccountInfo.ts index b83bb4a..b9ae6e3 100644 --- a/lib/supabase/account_info/insertAccountInfo.ts +++ b/lib/supabase/account_info/insertAccountInfo.ts @@ -19,4 +19,3 @@ export async function insertAccountInfo( return data || null; } - diff --git a/lib/supabase/account_organization_ids/addAccountToOrganization.ts b/lib/supabase/account_organization_ids/addAccountToOrganization.ts index 846ae7b..115a571 100644 --- a/lib/supabase/account_organization_ids/addAccountToOrganization.ts +++ b/lib/supabase/account_organization_ids/addAccountToOrganization.ts @@ -27,4 +27,3 @@ export async function addAccountToOrganization( return data?.id || null; } - diff --git a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts index 989fe9b..4ca7ad8 100644 --- a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts +++ b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts @@ -10,7 +10,7 @@ export type AccountWorkspaceRow = Omit & { * Get all workspaces for an account, with full info. * Returns raw data - formatting should be done by caller. * - * @param accountId The owner's account ID + * @param accountId - The owner's account ID * @returns Array of raw workspace rows from database */ export async function getAccountWorkspaceIds(accountId: string): Promise { @@ -39,4 +39,3 @@ export async function getAccountWorkspaceIds(accountId: string): Promise { + if (!accountId || !workspaceId) return null; + + const { data, error } = await supabase + .from("account_workspace_ids") + .insert({ + account_id: accountId, + workspace_id: workspaceId, + }) + .select("id") + .single(); + + if (error) { + return null; + } + + return data?.id || null; +} diff --git a/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts index 966c468..82b6bac 100644 --- a/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts +++ b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountWithSocials } from "../selectAccountWithSocials"; + const mockFrom = vi.fn(); const mockSelect = vi.fn(); const mockEq = vi.fn(); @@ -11,8 +13,6 @@ vi.mock("@/lib/supabase/serverClient", () => ({ }, })); -import { selectAccountWithSocials } from "../selectAccountWithSocials"; - describe("selectAccountWithSocials", () => { beforeEach(() => { vi.clearAllMocks(); @@ -27,7 +27,13 @@ describe("selectAccountWithSocials", () => { name: "Test Artist", timestamp: 1704067200000, account_socials: [{ id: "social-1", platform: "spotify" }], - account_info: [{ id: "info-1", image: "https://example.com/image.jpg", updated_at: "2024-01-01T12:00:00Z" }], + account_info: [ + { + id: "info-1", + image: "https://example.com/image.jpg", + updated_at: "2024-01-01T12:00:00Z", + }, + ], }; mockSingle.mockResolvedValue({ data: mockData, error: null }); diff --git a/lib/supabase/artist_organization_ids/addArtistToOrganization.ts b/lib/supabase/artist_organization_ids/addArtistToOrganization.ts index 5f8f4fe..7714adb 100644 --- a/lib/supabase/artist_organization_ids/addArtistToOrganization.ts +++ b/lib/supabase/artist_organization_ids/addArtistToOrganization.ts @@ -31,4 +31,3 @@ export async function addArtistToOrganization( return data?.id || null; } - diff --git a/lib/supabase/artist_organization_ids/getArtistsByOrganization.ts b/lib/supabase/artist_organization_ids/getArtistsByOrganization.ts index d67a855..3a3fb49 100644 --- a/lib/supabase/artist_organization_ids/getArtistsByOrganization.ts +++ b/lib/supabase/artist_organization_ids/getArtistsByOrganization.ts @@ -43,4 +43,3 @@ export async function getArtistsByOrganization(organizationIds: string[]): Promi return (data || []) as unknown as ArtistOrgRow[]; } - diff --git a/lib/supabase/files/createFileRecord.ts b/lib/supabase/files/createFileRecord.ts index 6b06744..3182de1 100644 --- a/lib/supabase/files/createFileRecord.ts +++ b/lib/supabase/files/createFileRecord.ts @@ -25,10 +25,10 @@ export interface CreateFileRecordParams { /** * Create a file record in the database + * + * @param params */ -export async function createFileRecord( - params: CreateFileRecordParams -): Promise { +export async function createFileRecord(params: CreateFileRecordParams): Promise { const { ownerAccountId, artistAccountId, @@ -61,4 +61,3 @@ export async function createFileRecord( return data; } - diff --git a/lib/supabase/song_artists/insertSongArtists.ts b/lib/supabase/song_artists/insertSongArtists.ts index bec45fa..69878d6 100644 --- a/lib/supabase/song_artists/insertSongArtists.ts +++ b/lib/supabase/song_artists/insertSongArtists.ts @@ -5,13 +5,12 @@ export type SongArtistInsert = TablesInsert<"song_artists">; /** * Inserts song-artist relationships, skipping duplicates. + * + * @param songArtists */ -export async function insertSongArtists( - songArtists: SongArtistInsert[] -): Promise { +export async function insertSongArtists(songArtists: SongArtistInsert[]): Promise { const records = songArtists.filter( - (record): record is SongArtistInsert => - Boolean(record.song) && Boolean(record.artist) + (record): record is SongArtistInsert => Boolean(record.song) && Boolean(record.artist), ); if (records.length === 0) { @@ -19,16 +18,12 @@ export async function insertSongArtists( } const deduped = [ - ...new Map( - records.map((record) => [`${record.song}-${record.artist}`, record]) - ).values(), + ...new Map(records.map(record => [`${record.song}-${record.artist}`, record])).values(), ]; - const { error } = await supabase - .from("song_artists") - .upsert(deduped, { - onConflict: "song,artist", - }); + const { error } = await supabase.from("song_artists").upsert(deduped, { + onConflict: "song,artist", + }); if (error) { throw new Error(`Failed to insert song artists: ${error.message}`); diff --git a/lib/supabase/storage/uploadFileByKey.ts b/lib/supabase/storage/uploadFileByKey.ts index c04f2bd..ae14917 100644 --- a/lib/supabase/storage/uploadFileByKey.ts +++ b/lib/supabase/storage/uploadFileByKey.ts @@ -3,6 +3,12 @@ import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; /** * Upload file to Supabase storage by key + * + * @param key + * @param file + * @param options + * @param options.contentType + * @param options.upsert */ export async function uploadFileByKey( key: string, @@ -10,17 +16,14 @@ export async function uploadFileByKey( options: { contentType?: string; upsert?: boolean; - } = {} + } = {}, ): Promise { - const { error } = await supabase.storage - .from(SUPABASE_STORAGE_BUCKET) - .upload(key, file, { - contentType: options.contentType || "application/octet-stream", - upsert: options.upsert ?? false, - }); + const { error } = await supabase.storage.from(SUPABASE_STORAGE_BUCKET).upload(key, file, { + contentType: options.contentType || "application/octet-stream", + upsert: options.upsert ?? false, + }); if (error) { throw new Error(`Failed to upload file: ${error.message}`); } } - diff --git a/lib/telegram/sendErrorNotification.ts b/lib/telegram/sendErrorNotification.ts index ad9fe2e..1e6404e 100644 --- a/lib/telegram/sendErrorNotification.ts +++ b/lib/telegram/sendErrorNotification.ts @@ -18,12 +18,7 @@ export interface ErrorContext { */ function formatErrorMessage(context: ErrorContext): string { const { path, error, roomId, email } = context; - const lines = [ - `*Error in ${path}*`, - "", - `*Error:* ${error.name}`, - `*Message:* ${error.message}`, - ]; + const lines = [`*Error in ${path}*`, "", `*Error:* ${error.name}`, `*Message:* ${error.message}`]; if (roomId) { lines.push(`*Room ID:* ${roomId}`); diff --git a/lib/transcribe/formatTranscriptMd.ts b/lib/transcribe/formatTranscriptMd.ts index dc052de..77a5b72 100644 --- a/lib/transcribe/formatTranscriptMd.ts +++ b/lib/transcribe/formatTranscriptMd.ts @@ -33,4 +33,3 @@ export function formatTranscriptMd( return md; } - diff --git a/lib/transcribe/index.ts b/lib/transcribe/index.ts index 38430b5..df8cd37 100644 --- a/lib/transcribe/index.ts +++ b/lib/transcribe/index.ts @@ -11,4 +11,3 @@ export { saveAudioToFiles } from "./saveAudioToFiles"; export { saveTranscriptToFiles } from "./saveTranscriptToFiles"; export { processAudioTranscription } from "./processAudioTranscription"; export * from "./types"; - diff --git a/lib/transcribe/processAudioTranscription.ts b/lib/transcribe/processAudioTranscription.ts index 5663be0..0e05905 100644 --- a/lib/transcribe/processAudioTranscription.ts +++ b/lib/transcribe/processAudioTranscription.ts @@ -7,6 +7,8 @@ import { ProcessTranscriptionParams, ProcessTranscriptionResult } from "./types" /** * Fetches audio from URL, transcribes it with OpenAI Whisper, and saves both * the original audio and transcript markdown to the customer's files. + * + * @param params */ export async function processAudioTranscription( params: ProcessTranscriptionParams, @@ -64,10 +66,13 @@ export async function processAudioTranscription( }; } +/** + * + * @param contentType + */ function getExtensionFromContentType(contentType: string): string { if (contentType.includes("wav")) return "wav"; if (contentType.includes("m4a") || contentType.includes("mp4")) return "m4a"; if (contentType.includes("webm")) return "webm"; return "mp3"; } - diff --git a/lib/transcribe/saveAudioToFiles.ts b/lib/transcribe/saveAudioToFiles.ts index 4c082e6..2124e51 100644 --- a/lib/transcribe/saveAudioToFiles.ts +++ b/lib/transcribe/saveAudioToFiles.ts @@ -2,9 +2,19 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveAudioParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveAudioToFiles(params: SaveAudioParams): Promise { - const { audioBlob, contentType, fileName, ownerAccountId, artistAccountId, title = "Audio" } = - params; + const { + audioBlob, + contentType, + fileName, + ownerAccountId, + artistAccountId, + title = "Audio", + } = params; const safeFileName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); const storageKey = `files/${ownerAccountId}/${artistAccountId}/${safeFileName}`; diff --git a/lib/transcribe/saveTranscriptToFiles.ts b/lib/transcribe/saveTranscriptToFiles.ts index 627feb6..fa7518c 100644 --- a/lib/transcribe/saveTranscriptToFiles.ts +++ b/lib/transcribe/saveTranscriptToFiles.ts @@ -2,6 +2,10 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveTranscriptParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveTranscriptToFiles(params: SaveTranscriptParams): Promise { const { markdown, ownerAccountId, artistAccountId, title = "Transcription" } = params; diff --git a/lib/transcribe/transcribeAudio.ts b/lib/transcribe/transcribeAudio.ts index 2dcf31b..429eee4 100644 --- a/lib/transcribe/transcribeAudio.ts +++ b/lib/transcribe/transcribeAudio.ts @@ -56,7 +56,7 @@ export async function transcribeAudio( const data: WhisperVerboseResponse = await response.json(); // Map OpenAI segments to our chunk format - const chunks = data.segments?.map((seg) => ({ + const chunks = data.segments?.map(seg => ({ timestamp: [seg.start, seg.end] as [number, number], text: seg.text, })); @@ -67,4 +67,3 @@ export async function transcribeAudio( language: data.language, }; } - diff --git a/lib/transcribe/types.ts b/lib/transcribe/types.ts index 68b134f..916e699 100644 --- a/lib/transcribe/types.ts +++ b/lib/transcribe/types.ts @@ -56,6 +56,8 @@ export interface ProcessTranscriptionResult { /** * Formats transcription errors into user-friendly messages. * Centralizes error message logic to avoid duplication. + * + * @param error */ export function formatTranscriptionError(error: unknown): { message: string; status: number } { const rawMessage = error instanceof Error ? error.message : "Transcription failed"; @@ -64,7 +66,10 @@ export function formatTranscriptionError(error: unknown): { message: string; sta return { message: "OpenAI API key is not configured", status: 500 }; } if (rawMessage.includes("fetch audio") || rawMessage.includes("Failed to fetch")) { - return { message: "Could not fetch the audio file. Please check the URL is accessible.", status: 400 }; + return { + message: "Could not fetch the audio file. Please check the URL is accessible.", + status: 400, + }; } if (rawMessage.includes("25 MB") || rawMessage.includes("file size")) { return { message: "Audio file exceeds the 25MB limit", status: 413 }; @@ -75,4 +80,3 @@ export function formatTranscriptionError(error: unknown): { message: string; sta return { message: rawMessage, status: 500 }; } - diff --git a/lib/workspaces/createWorkspaceInDb.ts b/lib/workspaces/createWorkspaceInDb.ts new file mode 100644 index 0000000..d7684c5 --- /dev/null +++ b/lib/workspaces/createWorkspaceInDb.ts @@ -0,0 +1,55 @@ +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { + selectAccountWithSocials, + type AccountWithSocials, +} from "@/lib/supabase/accounts/selectAccountWithSocials"; +import { insertAccountWorkspaceId } from "@/lib/supabase/account_workspace_ids/insertAccountWorkspaceId"; +import { addArtistToOrganization } from "@/lib/supabase/artist_organization_ids/addArtistToOrganization"; + +/** + * Result of creating a workspace in the database. + */ +export type CreateWorkspaceResult = AccountWithSocials & { + account_id: string; + isWorkspace: boolean; +}; + +/** + * Create a new workspace account in the database and associate it with an owner account. + * + * @param name - Name of the workspace to create + * @param accountId - ID of the owner account that will have access to this workspace + * @param organizationId - Optional organization ID to link the new workspace to + * @returns Created workspace object or null if creation failed + */ +export async function createWorkspaceInDb( + name: string, + accountId: string, + organizationId?: string, +): Promise { + try { + const account = await insertAccount({ name }); + + const accountInfo = await insertAccountInfo({ account_id: account.id }); + if (!accountInfo) return null; + + const workspace = await selectAccountWithSocials(account.id); + if (!workspace) return null; + + const linkId = await insertAccountWorkspaceId(accountId, account.id); + if (!linkId) return null; + + if (organizationId) { + await addArtistToOrganization(account.id, organizationId); + } + + return { + ...workspace, + account_id: workspace.id, + isWorkspace: true, + }; + } catch (error) { + return null; + } +} diff --git a/lib/workspaces/createWorkspacePostHandler.ts b/lib/workspaces/createWorkspacePostHandler.ts new file mode 100644 index 0000000..0a7c7ac --- /dev/null +++ b/lib/workspaces/createWorkspacePostHandler.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateWorkspaceBody } from "@/lib/workspaces/validateCreateWorkspaceBody"; +import { createWorkspaceInDb } from "@/lib/workspaces/createWorkspaceInDb"; + +/** + * Handler for POST /api/workspaces. + * + * Creates a new workspace account. Requires authentication via x-api-key header. + * The account ID is inferred from the API key, unless an account_id is provided + * in the request body by an organization API key with access to that account. + * + * Request body: + * - name (optional): The name of the workspace to create. Defaults to "Untitled". + * - account_id (optional): The ID of the account to create the workspace for (UUID). + * Only used by organization API keys creating workspaces on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new workspace to (UUID). + * If provided, the workspace will appear in that organization's view. + * + * @param request - The request object containing JSON body + * @returns A NextResponse with workspace data or error + */ +export async function createWorkspacePostHandler(request: NextRequest): Promise { + const validated = await validateCreateWorkspaceBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const workspace = await createWorkspaceInDb( + validated.name, + validated.accountId, + validated.organizationId, + ); + + if (!workspace) { + return NextResponse.json( + { status: "error", error: "Failed to create workspace" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ workspace }, { status: 201, headers: getCorsHeaders() }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create workspace"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/workspaces/validateCreateWorkspaceBody.ts b/lib/workspaces/validateCreateWorkspaceBody.ts new file mode 100644 index 0000000..7833846 --- /dev/null +++ b/lib/workspaces/validateCreateWorkspaceBody.ts @@ -0,0 +1,71 @@ +import { NextRequest, 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 createWorkspaceBodySchema = z.object({ + name: z.string().optional(), + account_id: z.uuid({ message: "account_id must be a valid UUID" }).optional(), + organization_id: z + .uuid({ message: "organization_id must be a valid UUID" }) + .optional() + .nullable(), +}); + +export type CreateWorkspaceBody = z.infer; + +export type ValidatedCreateWorkspaceRequest = { + name: string; + accountId: string; + organizationId?: string; +}; + +/** + * Validates POST /api/workspaces request including auth headers, body parsing, schema validation, + * organization access authorization, and account access authorization. + * + * Supports both: + * - x-api-key header + * - Authorization: Bearer header + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated request data if validation passes. + */ +export async function validateCreateWorkspaceBody( + request: NextRequest, +): Promise { + // Parse and validate the request body first + const body = await safeParseJson(request); + const result = createWorkspaceBodySchema.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() }, + ); + } + + // Validate auth and authorization using the centralized utility + const authContext = await validateAuthContext(request, { + accountId: result.data.account_id, + organizationId: result.data.organization_id, + }); + + if (authContext instanceof NextResponse) { + return authContext; + } + + // Default name to "Untitled" if not provided + const workspaceName = result.data.name?.trim() || "Untitled"; + + return { + name: workspaceName, + accountId: authContext.accountId, + organizationId: result.data.organization_id ?? undefined, + }; +} diff --git a/lib/youtube/fetchYouTubeChannelInfo.ts b/lib/youtube/fetchYouTubeChannelInfo.ts index 2ffd976..1515551 100644 --- a/lib/youtube/fetchYouTubeChannelInfo.ts +++ b/lib/youtube/fetchYouTubeChannelInfo.ts @@ -148,4 +148,3 @@ export async function fetchYouTubeChannelInfo({ }; } } - diff --git a/lib/youtube/getDefaultDateRange.ts b/lib/youtube/getDefaultDateRange.ts index 4e6e92b..d2f5495 100644 --- a/lib/youtube/getDefaultDateRange.ts +++ b/lib/youtube/getDefaultDateRange.ts @@ -14,4 +14,3 @@ export function getDefaultDateRange(): { startDate: string; endDate: string } { endDate: endDate.toISOString().split("T")[0], }; } - diff --git a/lib/youtube/isTokenExpired.ts b/lib/youtube/isTokenExpired.ts index 81a044a..d286291 100644 --- a/lib/youtube/isTokenExpired.ts +++ b/lib/youtube/isTokenExpired.ts @@ -10,4 +10,3 @@ export function isTokenExpired(expiresAt: string): boolean { const oneMinuteInMs = 60 * 1000; return expirationTime <= now + oneMinuteInMs; } -