From 8202ea512c693f78ecdc83dd2d47943f87cd33e7 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Mon, 13 Apr 2026 14:56:12 -0500 Subject: [PATCH] fix: close authorization bypass and data-integrity bugs across dashboard Security (critical): - Scope member lookups to parent resource (repository_id / organization_id) in updateRepositoryMemberRole, removeRepositoryMember, updateOrganizationMemberRole, and removeOrganizationMember to prevent cross-tenant escalation via crafted memberId - Replace unvalidated currentOrganizationId cookie reads with getAccountContext() (validates org membership) in review page and repo detail data loaders Bugs: - Add missing string-UUID branch in repository_id filter (raw SQL paths) - Pass actual username to RepoDetailClient instead of empty string - Remove misleading React.cache() on getAllOptimizationEventsImpl (object arg means reference equality never hits) - Use create() result directly in addOrganizationMember to avoid NPE from unnecessary re-fetch - Separate null-session redirect from null-event 404 in profiler page Tests: - Rewrite action.test.ts: org payload for Prisma findMany path, proper $queryRaw tagged-template mock for raw SQL path, verify repository_id filter is actually applied --- .../src/app/(dashboard)/members/action.ts | 23 +- .../repositories/[repositoryId]/action.ts | 18 +- .../repositories/[repositoryId]/data.ts | 11 +- .../repositories/[repositoryId]/page.tsx | 1 + .../[repositoryId]/repo-detail-client.tsx | 4 +- .../review-optimizations/[traceId]/action.ts | 9 +- .../[traceId]/profiler/page.tsx | 8 +- .../__tests__/action.test.ts | 149 +++--- .../review-optimizations/action.ts | 506 +++++++++--------- 9 files changed, 375 insertions(+), 354 deletions(-) diff --git a/js/cf-webapp/src/app/(dashboard)/members/action.ts b/js/cf-webapp/src/app/(dashboard)/members/action.ts index d39b3ef6a..da1fdcdd7 100644 --- a/js/cf-webapp/src/app/(dashboard)/members/action.ts +++ b/js/cf-webapp/src/app/(dashboard)/members/action.ts @@ -134,16 +134,15 @@ export async function addOrganizationMember( // Check if user exists in our database let user = await getUserById(invitedUserId) - // If user doesn't exist, create them and re-fetch for consistent types + // If user doesn't exist, create them if (!user) { - await prisma.users.create({ + user = await prisma.users.create({ data: { user_id: invitedUserId, github_username: invitedUser.username, onboarding_completed: false, }, }) - user = await getUserById(invitedUserId) } // Add user to organization members @@ -200,8 +199,8 @@ export async function updateOrganizationMemberRole( }, select: { role: true }, }), - prisma.organization_members.findUnique({ - where: { id: memberId }, + prisma.organization_members.findFirst({ + where: { id: memberId, organization_id: organizationId }, select: { id: true, role: true, user_id: true }, }), ]) @@ -210,17 +209,21 @@ export async function updateOrganizationMemberRole( return createErrorResponse("Organization not found") } + if (!targetMember) { + return createErrorResponse("Member not found in this organization") + } + // Only admins and owners can change roles if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") { return createErrorResponse("Only admins can change member roles") } // Don't allow changing owner role - if (targetMember?.role === "owner") { + if (targetMember.role === "owner") { return createErrorResponse("Cannot change owner role") } - if (targetMember?.user_id === currentUserId) { + if (targetMember.user_id === currentUserId) { return createErrorResponse("Cannot change your own role as the only admin") } @@ -253,14 +256,14 @@ export async function removeOrganizationMember( }, select: { role: true }, }), - prisma.organization_members.findUnique({ - where: { id: memberId }, + prisma.organization_members.findFirst({ + where: { id: memberId, organization_id: organizationId }, select: { id: true, role: true, user_id: true }, }), ]) if (!targetMember) { - return createErrorResponse("Member not found") + return createErrorResponse("Member not found in this organization") } // Cannot remove owner diff --git a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/action.ts b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/action.ts index f46443ad9..bf7f8e29e 100644 --- a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/action.ts +++ b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/action.ts @@ -384,8 +384,8 @@ export async function updateRepositoryMemberRole( where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } }, select: { role: true }, }), - prisma.repository_members.findUnique({ - where: { id: memberId }, + prisma.repository_members.findFirst({ + where: { id: memberId, repository_id: repoId }, select: { id: true, role: true, user_id: true }, }), ]) @@ -394,17 +394,21 @@ export async function updateRepositoryMemberRole( return createErrorResponse("Repository not found") } + if (!targetMember) { + return createErrorResponse("Member not found in this repository") + } + // Only admins and owners can change roles if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") { return createErrorResponse("Only admins can change member roles") } // Don't allow changing owner role - if (targetMember?.role === "owner") { + if (targetMember.role === "owner") { return createErrorResponse("Cannot change owner role") } - if (targetMember?.user_id === currentUserId) { + if (targetMember.user_id === currentUserId) { return createErrorResponse("Cannot change your own role") } @@ -436,14 +440,14 @@ export async function removeRepositoryMember( where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } }, select: { role: true }, }), - prisma.repository_members.findUnique({ - where: { id: memberId }, + prisma.repository_members.findFirst({ + where: { id: memberId, repository_id: repoId }, select: { id: true, role: true, user_id: true }, }), ]) if (!targetMember) { - return createErrorResponse("Member not found") + return createErrorResponse("Member not found in this repository") } // Cannot remove owner diff --git a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/data.ts b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/data.ts index 0d20ff05a..8b8d4cab4 100644 --- a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/data.ts +++ b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/data.ts @@ -1,8 +1,7 @@ "use server" import { auth0 } from "@/lib/auth0" -import { cookies } from "next/headers" -import type { AccountPayload } from "@codeflash-ai/common" +import { getAccountContext } from "@/lib/server/get-account-context" import { getRepositoryById, getOptimizationCountsByRepo, @@ -24,10 +23,7 @@ export async function getRepoDetailInitData(repositoryId: string) { const userId = session.user.sub const username = session.user.nickname - const cookieStore = await cookies() - const orgId = cookieStore.get("currentOrganizationId")?.value - - const payload: AccountPayload = orgId ? { orgId } : { userId, username } + const payload = await getAccountContext() const repository = await getRepositoryById(payload, repositoryId) if (!repository) { @@ -72,7 +68,8 @@ export async function getRepoDetailInitData(repositoryId: string) { return { userId, - orgId: orgId ?? null, + username, + orgId: "orgId" in payload ? payload.orgId : null, repository, stats: { totalAttempts: totalAttempts ?? 0, diff --git a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/page.tsx b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/page.tsx index 5dfbaae10..96eb96ab9 100644 --- a/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/page.tsx +++ b/js/cf-webapp/src/app/(dashboard)/repositories/[repositoryId]/page.tsx @@ -39,6 +39,7 @@ export default async function RepositoryDetailPage({ ({ })) // Use realistic test fixtures: valid UUIDs and Auth0-style user IDs -const mockPayload = { userId: "github|12345", username: "testuser" } +const mockOrgPayload = { orgId: "org-a1b2c3d4-e5f6-7890" } +const mockPersonalPayload = { userId: "github|12345", username: "testuser" } const mockRepoIds = ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f678-9012-bcde-f12345678901"] const mockEvents = [ @@ -47,6 +48,13 @@ const mockFeatures = [ }, ] +/** Helper: extract SQL pattern from a $queryRaw tagged template mock call */ +function getTaggedSql(mockFn: any, callIndex: number): string { + const args = mockFn.mock.calls[callIndex] + const strings = args[0] as string[] + return strings.join("$?") +} + describe("getAllOptimizationEvents", () => { let getAllOptimizationEvents: typeof import("../action").getAllOptimizationEvents @@ -56,29 +64,32 @@ describe("getAllOptimizationEvents", () => { repos: [], } as any) + // $queryRaw is used as a tagged template literal — auto-mock doesn't create it + ;(prisma as any).$queryRaw = vi.fn() + const mod = await import("../action") getAllOptimizationEvents = mod.getAllOptimizationEvents }) - describe("Path B: standard Prisma query", () => { + describe("Path B: standard Prisma query (org account)", () => { + // Org accounts use Prisma findMany/count (not raw SQL) when not sorting by review_quality it("calls findMany and count in parallel", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce(mockEvents) - .mockResolvedValueOnce([{ count: BigInt(2) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(2) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) - await getAllOptimizationEvents({ payload: mockPayload as any }) + await getAllOptimizationEvents({ payload: mockOrgPayload as any }) - expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2) + expect(prisma.optimization_events.findMany).toHaveBeenCalledTimes(1) + expect(prisma.optimization_events.count).toHaveBeenCalledTimes(1) }) it("batch-fetches optimization_features by trace_id array (not N+1)", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce(mockEvents) - .mockResolvedValueOnce([{ count: BigInt(2) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(2) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue(mockFeatures as any) - await getAllOptimizationEvents({ payload: mockPayload as any }) + await getAllOptimizationEvents({ payload: mockOrgPayload as any }) // Single batch query with all trace IDs — NOT one per event expect(prisma.optimization_features.findMany).toHaveBeenCalledTimes(1) @@ -93,12 +104,11 @@ describe("getAllOptimizationEvents", () => { }) it("merges review_quality into events", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce(mockEvents) - .mockResolvedValueOnce([{ count: BigInt(2) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(2) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue(mockFeatures as any) - const result = await getAllOptimizationEvents({ payload: mockPayload as any }) + const result = await getAllOptimizationEvents({ payload: mockOrgPayload as any }) expect((result.events[0] as any).review_quality).toBe("high") expect((result.events[0] as any).review_explanation).toBe("Great optimization") @@ -106,120 +116,120 @@ describe("getAllOptimizationEvents", () => { }) it("returns totalCount from count query", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ count: BigInt(42) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([]) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(42) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) - const result = await getAllOptimizationEvents({ payload: mockPayload as any }) + const result = await getAllOptimizationEvents({ payload: mockOrgPayload as any }) expect(result.totalCount).toBe(42) }) it("applies pagination with skip and take", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ count: BigInt(0) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([]) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(0) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, page: 3, pageSize: 25, }) - // Check that OFFSET is calculated correctly in the SQL - const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string - expect(sql).toContain("OFFSET 50") // (3 - 1) * 25 - expect(sql).toContain("LIMIT 25") + expect(prisma.optimization_events.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 50, // (3 - 1) * 25 + take: 25, + }), + ) }) it("uses default sort (created_at desc) when no sort provided", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ count: BigInt(0) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([]) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(0) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) - await getAllOptimizationEvents({ payload: mockPayload as any }) + await getAllOptimizationEvents({ payload: mockOrgPayload as any }) - const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string - expect(sql).toContain("ORDER BY oe.created_at DESC") + expect(prisma.optimization_events.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { created_at: "desc" }, + }), + ) }) it("applies search filter", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ count: BigInt(0) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([]) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(0) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, search: "calc", }) - // Check that search is included in the SQL - const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string - expect(sql).toContain("oe.function_name ILIKE $1") - expect(sql).toContain("oe.file_path ILIKE $1") - expect(sql).toContain("r.full_name ILIKE $1") - // Check params include the search term - const params = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0].slice(1) - expect(params[0]).toBe("%calc%") + // Check findMany was called with a search-containing where clause + const call = vi.mocked(prisma.optimization_events.findMany).mock.calls[0][0] as any + // Should have AND with OR containing the search fields + expect(call.where.AND).toBeDefined() + const orClause = call.where.AND.find((c: any) => c.OR) + expect(orClause).toBeDefined() + expect(orClause.OR).toHaveLength(3) // function_name, file_path, repository.full_name }) it("applies repository_id filter", async () => { - vi.mocked(prisma.$queryRawUnsafe) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ count: BigInt(0) }]) + vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([]) + vi.mocked(prisma.optimization_events.count).mockResolvedValue(0) vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([]) await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, filter: { repository_id: mockRepoIds[0] }, }) - // In the new UNION-based implementation, additional filters are NOT supported - // because they would require complex WHERE clause merging across UNION branches. - // This test now verifies the query runs without errors (which is a valid regression test). - expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2) + const call = vi.mocked(prisma.optimization_events.findMany).mock.calls[0][0] as any + // The repository_id filter should be in the AND clause + const repoFilter = call.where.AND.find((c: any) => c.repository_id !== undefined) + expect(repoFilter).toBeDefined() + expect(repoFilter.repository_id).toBe(mockRepoIds[0]) }) }) describe("Path A: raw SQL query (review_quality sort/filter)", () => { it("triggers when sort includes review_quality", async () => { - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce([]) // events .mockResolvedValueOnce([{ count: BigInt(0) }]) // count await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, sort: { review_quality: "desc" }, }) - expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2) + expect((prisma as any).$queryRaw).toHaveBeenCalledTimes(2) // Should NOT use standard Prisma findMany expect(prisma.optimization_events.findMany).not.toHaveBeenCalled() }) it("triggers when filter includes review_quality", async () => { - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]) await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, filter: { review_quality: "high" }, }) - expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2) + expect((prisma as any).$queryRaw).toHaveBeenCalledTimes(2) }) it("returns correct totalCount from BigInt conversion", async () => { - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(99) }]) const result = await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, sort: { review_quality: "asc" }, }) @@ -238,12 +248,12 @@ describe("getAllOptimizationEvents", () => { repo_id: mockRepoIds[0], }, ] - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce(rawEvents) .mockResolvedValueOnce([{ count: BigInt(1) }]) const result = await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, sort: { review_quality: "desc" }, }) @@ -266,12 +276,12 @@ describe("getAllOptimizationEvents", () => { repo_id: null, }, ] - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce(rawEvents) .mockResolvedValueOnce([{ count: BigInt(1) }]) const result = await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, sort: { review_quality: "desc" }, }) @@ -279,16 +289,17 @@ describe("getAllOptimizationEvents", () => { }) it("includes LEFT JOIN in raw SQL queries", async () => { - vi.mocked(prisma.$queryRawUnsafe) + ;(prisma as any).$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]) await getAllOptimizationEvents({ - payload: mockPayload as any, + payload: mockOrgPayload as any, sort: { review_quality: "desc" }, }) - const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string + // $queryRaw is a tagged template — first arg is TemplateStringsArray + const sql = getTaggedSql((prisma as any).$queryRaw, 0) expect(sql).toContain("LEFT JOIN optimization_features") expect(sql).toContain("LEFT JOIN repositories") }) @@ -301,7 +312,7 @@ describe("getAllOptimizationEvents", () => { repos: [], } as any) - const result = await getAllOptimizationEvents({ payload: mockPayload as any }) + const result = await getAllOptimizationEvents({ payload: mockPersonalPayload as any }) expect(result.events).toEqual([]) expect(result.totalCount).toBe(0) }) diff --git a/js/cf-webapp/src/app/(dashboard)/review-optimizations/action.ts b/js/cf-webapp/src/app/(dashboard)/review-optimizations/action.ts index 9caaaa64c..aae05ce6f 100644 --- a/js/cf-webapp/src/app/(dashboard)/review-optimizations/action.ts +++ b/js/cf-webapp/src/app/(dashboard)/review-optimizations/action.ts @@ -79,86 +79,87 @@ export const getRepositoriesWithStagingEvents = withTiming( getRepositoriesWithStagingEventsImpl, ) -// Cached implementation for getAllOptimizationEvents -// React cache() deduplicates calls with identical arguments within a single request -const getAllOptimizationEventsImpl = cache( - async ({ - payload, - search, - filter, - sort, - page = 1, - pageSize = 10, - }: { - payload: AccountPayload - search?: string - filter?: Record - sort?: { [key: string]: "asc" | "desc" } - page?: number - pageSize?: number - }) => { - const repoIds = (await getRepositoriesForAccountCached(payload)).repoIds +// Note: React cache() is NOT used here because this function takes an object argument +// (reference equality means cache never hits). Deduplication happens at a higher level. +const getAllOptimizationEventsImpl = async ({ + payload, + search, + filter, + sort, + page = 1, + pageSize = 10, +}: { + payload: AccountPayload + search?: string + filter?: Record + sort?: { [key: string]: "asc" | "desc" } + page?: number + pageSize?: number +}) => { + const repoIds = (await getRepositoriesForAccountCached(payload)).repoIds - if (repoIds.length === 0) { - return { events: [], totalCount: 0 } - } + if (repoIds.length === 0) { + return { events: [], totalCount: 0 } + } - const needsOptimizationFeaturesJoin = - (sort && Object.keys(sort).some(k => k.toLowerCase() === "review_quality")) || - (filter && Object.keys(filter).some(k => k.toLowerCase() === "review_quality")) + const needsOptimizationFeaturesJoin = + (sort && Object.keys(sort).some(k => k.toLowerCase() === "review_quality")) || + (filter && Object.keys(filter).some(k => k.toLowerCase() === "review_quality")) - if (needsOptimizationFeaturesJoin) { - // Raw SQL path for review_quality sorting/filtering - const whereFragments: Prisma.Sql[] = [Prisma.sql`oe.is_staging = true`] + if (needsOptimizationFeaturesJoin) { + // Raw SQL path for review_quality sorting/filtering + const whereFragments: Prisma.Sql[] = [Prisma.sql`oe.is_staging = true`] - if ("orgId" in payload) { - whereFragments.push(Prisma.sql`oe.repository_id IN (${Prisma.join(repoIds)})`) - } else { - // For personal accounts, use OR pattern in WHERE (raw SQL already, so bitmap merge is acceptable here - // since it's joined with optimization_features anyway). The primary bottleneck was the groupBy, - // which is now fixed above. This path is rarely hit (only when sorting by review_quality). - whereFragments.push( - Prisma.sql`( + if ("orgId" in payload) { + whereFragments.push(Prisma.sql`oe.repository_id IN (${Prisma.join(repoIds)})`) + } else { + // For personal accounts, use OR pattern in WHERE (raw SQL already, so bitmap merge is acceptable here + // since it's joined with optimization_features anyway). The primary bottleneck was the groupBy, + // which is now fixed above. This path is rarely hit (only when sorting by review_quality). + whereFragments.push( + Prisma.sql`( oe.repository_id IN (${Prisma.join(repoIds)}) OR oe.user_id = ${payload.userId} OR oe.current_username = ${payload.username} )`, - ) - } + ) + } - // Add search conditions - if (search) { - const searchPattern = `%${search}%` - whereFragments.push( - Prisma.sql`(oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})`, - ) + // Add search conditions + if (search) { + const searchPattern = `%${search}%` + whereFragments.push( + Prisma.sql`(oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})`, + ) + } + // Add filter conditions + if (filter) { + if (filter.status) { + whereFragments.push(Prisma.sql`oe.status = ${filter.status}`) } - // Add filter conditions - if (filter) { - if (filter.status) { - whereFragments.push(Prisma.sql`oe.status = ${filter.status}`) - } - if (filter.event_type) { - whereFragments.push(Prisma.sql`oe.event_type = ${filter.event_type}`) - } - if (filter.review_quality) { - whereFragments.push(Prisma.sql`of.review_quality = ${filter.review_quality}`) - } - if (filter.repository_id !== undefined) { - if (filter.repository_id === null) { - whereFragments.push(Prisma.sql`oe.repository_id IS NULL`) - } else if (filter.repository_id.not !== undefined && filter.repository_id.not === null) { - whereFragments.push(Prisma.sql`oe.repository_id IS NOT NULL`) - } + if (filter.event_type) { + whereFragments.push(Prisma.sql`oe.event_type = ${filter.event_type}`) + } + if (filter.review_quality) { + whereFragments.push(Prisma.sql`of.review_quality = ${filter.review_quality}`) + } + if (filter.repository_id !== undefined) { + if (filter.repository_id === null) { + whereFragments.push(Prisma.sql`oe.repository_id IS NULL`) + } else if (filter.repository_id.not !== undefined && filter.repository_id.not === null) { + whereFragments.push(Prisma.sql`oe.repository_id IS NOT NULL`) + } else if (typeof filter.repository_id === "string") { + whereFragments.push(Prisma.sql`oe.repository_id = ${filter.repository_id}`) } } - const whereClause = Prisma.join(whereFragments, " AND ") - const orderByClauses: Prisma.Sql[] = [] - if (sort && Object.keys(sort).length > 0) { - Object.entries(sort).forEach(([key, direction]) => { - const dir = direction.toUpperCase() === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC` - if (key.toLowerCase() === "review_quality") { - orderByClauses.push(Prisma.sql` + } + const whereClause = Prisma.join(whereFragments, " AND ") + const orderByClauses: Prisma.Sql[] = [] + if (sort && Object.keys(sort).length > 0) { + Object.entries(sort).forEach(([key, direction]) => { + const dir = direction.toUpperCase() === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC` + if (key.toLowerCase() === "review_quality") { + orderByClauses.push(Prisma.sql` CASE WHEN LOWER(of.review_quality) = 'high' THEN 3 WHEN LOWER(of.review_quality) = 'medium' THEN 2 @@ -166,20 +167,20 @@ const getAllOptimizationEventsImpl = cache( ELSE 0 END ${dir} `) - } else { - const col = key === "created_at" ? Prisma.sql`oe.created_at` : Prisma.raw(`oe.${key}`) - orderByClauses.push(Prisma.sql`${col} ${dir}`) - } - }) - } - if (!sort) { - orderByClauses.push(Prisma.sql`oe.created_at DESC`) - } - const orderByClause = Prisma.join(orderByClauses, ", ") - const paginationLimit = pageSize - const paginationOffset = (page - 1) * pageSize - const [events, countResult] = await Promise.all([ - prisma.$queryRaw` + } else { + const col = key === "created_at" ? Prisma.sql`oe.created_at` : Prisma.raw(`oe.${key}`) + orderByClauses.push(Prisma.sql`${col} ${dir}`) + } + }) + } + if (!sort) { + orderByClauses.push(Prisma.sql`oe.created_at DESC`) + } + const orderByClause = Prisma.join(orderByClauses, ", ") + const paginationLimit = pageSize + const paginationOffset = (page - 1) * pageSize + const [events, countResult] = await Promise.all([ + prisma.$queryRaw` SELECT oe.*, of.review_quality, @@ -194,134 +195,136 @@ const getAllOptimizationEventsImpl = cache( ORDER BY ${orderByClause} LIMIT ${paginationLimit} OFFSET ${paginationOffset} `, - prisma.$queryRaw<[{ count: bigint }]>` + prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(*) as count FROM optimization_events oe LEFT JOIN optimization_features of ON oe.trace_id = of.trace_id LEFT JOIN repositories r ON oe.repository_id = r.id WHERE ${whereClause} `, - ]) - const totalCount = Number(countResult[0].count) - // Repository data is already included from the JOIN - const eventsWithRepo = events.map( - ( - event: Record & { - repo_id?: string - repo_full_name?: string - repo_name?: string - }, - ) => ({ - ...event, - repository: event.repo_id - ? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name } - : null, - }), - ) - return { events: eventsWithRepo, totalCount } - } else { - // Standard Prisma query with native orderBy (optimized with UNION for personal accounts) - const orderBy = sort || { created_at: "desc" as const } + ]) + const totalCount = Number(countResult[0].count) + // Repository data is already included from the JOIN + const eventsWithRepo = events.map( + ( + event: Record & { + repo_id?: string + repo_full_name?: string + repo_name?: string + }, + ) => ({ + ...event, + repository: event.repo_id + ? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name } + : null, + }), + ) + return { events: eventsWithRepo, totalCount } + } else { + // Standard Prisma query with native orderBy (optimized with UNION for personal accounts) + const orderBy = sort || { created_at: "desc" as const } - let events - let totalCount + let events + let totalCount - if ("orgId" in payload) { - // Organization account: simple IN clause - const where = { - is_staging: true, - repository_id: { in: repoIds }, - } as any + if ("orgId" in payload) { + // Organization account: simple IN clause + const where = { + is_staging: true, + repository_id: { in: repoIds }, + } as any - if (search) { - where.AND = where.AND || [] - where.AND.push({ - OR: [ - { - function_name: { - contains: search, - mode: "insensitive" as const, - }, - }, - { - file_path: { - contains: search, - mode: "insensitive" as const, - }, - }, - { - repository: { - full_name: { - contains: search, - mode: "insensitive" as const, - }, - }, - }, - ], - }) - } - - if (filter) { - Object.keys(filter).forEach(key => { - if (key === "repository_id") { - where.AND = where.AND || [] - where.AND.push({ [key]: filter[key] }) - } else if (key !== "review_quality") { - where[key] = filter[key] - } - }) - } - - ;[events, totalCount] = await Promise.all([ - prisma.optimization_events.findMany({ - where, - orderBy, - skip: (page - 1) * pageSize, - take: pageSize, - include: { - repository: { - select: { id: true, full_name: true, name: true }, + if (search) { + where.AND = where.AND || [] + where.AND.push({ + OR: [ + { + function_name: { + contains: search, + mode: "insensitive" as const, }, }, - }), - prisma.optimization_events.count({ where }), - ]) - } else { - // Personal account: Use raw SQL with UNION for efficient index seeks - let searchCondition = Prisma.empty - if (search) { - const searchPattern = `%${search}%` - searchCondition = Prisma.sql`AND (oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})` - } + { + file_path: { + contains: search, + mode: "insensitive" as const, + }, + }, + { + repository: { + full_name: { + contains: search, + mode: "insensitive" as const, + }, + }, + }, + ], + }) + } - const filterFragments: Prisma.Sql[] = [] - if (filter) { - Object.entries(filter).forEach(([key, value]) => { - if (key === "status") { - filterFragments.push(Prisma.sql`AND oe.status = ${value}`) - } else if (key === "event_type") { - filterFragments.push(Prisma.sql`AND oe.event_type = ${value}`) - } else if (key === "repository_id") { - if (value === null) { - filterFragments.push(Prisma.sql`AND oe.repository_id IS NULL`) - } else if (value?.not === null) { - filterFragments.push(Prisma.sql`AND oe.repository_id IS NOT NULL`) - } + if (filter) { + Object.keys(filter).forEach(key => { + if (key === "repository_id") { + where.AND = where.AND || [] + where.AND.push({ [key]: filter[key] }) + } else if (key !== "review_quality") { + where[key] = filter[key] + } + }) + } + + ;[events, totalCount] = await Promise.all([ + prisma.optimization_events.findMany({ + where, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + repository: { + select: { id: true, full_name: true, name: true }, + }, + }, + }), + prisma.optimization_events.count({ where }), + ]) + } else { + // Personal account: Use raw SQL with UNION for efficient index seeks + let searchCondition = Prisma.empty + if (search) { + const searchPattern = `%${search}%` + searchCondition = Prisma.sql`AND (oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})` + } + + const filterFragments: Prisma.Sql[] = [] + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + if (key === "status") { + filterFragments.push(Prisma.sql`AND oe.status = ${value}`) + } else if (key === "event_type") { + filterFragments.push(Prisma.sql`AND oe.event_type = ${value}`) + } else if (key === "repository_id") { + if (value === null) { + filterFragments.push(Prisma.sql`AND oe.repository_id IS NULL`) + } else if (value?.not === null) { + filterFragments.push(Prisma.sql`AND oe.repository_id IS NOT NULL`) + } else if (typeof value === "string") { + filterFragments.push(Prisma.sql`AND oe.repository_id = ${value}`) } - }) - } - const filterConditions = - filterFragments.length > 0 ? Prisma.join(filterFragments, " ") : Prisma.empty + } + }) + } + const filterConditions = + filterFragments.length > 0 ? Prisma.join(filterFragments, " ") : Prisma.empty - const orderByDir = - typeof orderBy === "object" && orderBy.created_at === "asc" - ? Prisma.sql`ASC` - : Prisma.sql`DESC` + const orderByDir = + typeof orderBy === "object" && orderBy.created_at === "asc" + ? Prisma.sql`ASC` + : Prisma.sql`DESC` - const paginationLimit = pageSize - const paginationOffset = (page - 1) * pageSize + const paginationLimit = pageSize + const paginationOffset = (page - 1) * pageSize - const unionSubquery = Prisma.sql` + const unionSubquery = Prisma.sql` SELECT id FROM ( SELECT id FROM optimization_events WHERE is_staging = true @@ -335,8 +338,8 @@ const getAllOptimizationEventsImpl = cache( ) AS combined_ids ` - const [eventsResult, countResult] = await Promise.all([ - prisma.$queryRaw` + const [eventsResult, countResult] = await Promise.all([ + prisma.$queryRaw` WITH base_events AS ( SELECT oe.*, r.id as repo_id, r.full_name as repo_full_name, r.name as repo_name FROM optimization_events oe @@ -349,7 +352,7 @@ const getAllOptimizationEventsImpl = cache( ) SELECT * FROM base_events `, - prisma.$queryRaw<[{ count: bigint }]>` + prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(*) as count FROM optimization_events oe LEFT JOIN repositories r ON oe.repository_id = r.id @@ -357,64 +360,63 @@ const getAllOptimizationEventsImpl = cache( ${searchCondition} ${filterConditions} `, - ]) + ]) - totalCount = Number(countResult[0].count) - events = eventsResult.map( - ( - event: Record & { - repo_id?: string - repo_full_name?: string - repo_name?: string - }, - ) => ({ - ...event, - repository: event.repo_id - ? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name } - : null, - }), - ) - } - - // Batch-fetch review data for all events in a single query - const traceIds = (events as Array>).map( - (e: Record) => e.trace_id as string, + totalCount = Number(countResult[0].count) + events = eventsResult.map( + ( + event: Record & { + repo_id?: string + repo_full_name?: string + repo_name?: string + }, + ) => ({ + ...event, + repository: event.repo_id + ? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name } + : null, + }), ) - const features = - traceIds.length > 0 - ? await prisma.optimization_features.findMany({ - where: { trace_id: { in: traceIds } }, - select: { - trace_id: true, - review_quality: true, - review_explanation: true, - }, - }) - : [] - type ReviewFeature = { - trace_id: string - review_quality: string | null - review_explanation: string | null - } - const featuresMap = new Map( - (features as ReviewFeature[]).map((f: ReviewFeature) => [f.trace_id, f]), - ) - - const eventsWithReviewData = (events as Array>).map( - (event: Record) => { - const f = featuresMap.get(event.trace_id as string) - return { - ...event, - review_quality: f?.review_quality || null, - review_explanation: f?.review_explanation || null, - } - }, - ) - - return { events: eventsWithReviewData, totalCount } } - }, -) + + // Batch-fetch review data for all events in a single query + const traceIds = (events as Array>).map( + (e: Record) => e.trace_id as string, + ) + const features = + traceIds.length > 0 + ? await prisma.optimization_features.findMany({ + where: { trace_id: { in: traceIds } }, + select: { + trace_id: true, + review_quality: true, + review_explanation: true, + }, + }) + : [] + type ReviewFeature = { + trace_id: string + review_quality: string | null + review_explanation: string | null + } + const featuresMap = new Map( + (features as ReviewFeature[]).map((f: ReviewFeature) => [f.trace_id, f]), + ) + + const eventsWithReviewData = (events as Array>).map( + (event: Record) => { + const f = featuresMap.get(event.trace_id as string) + return { + ...event, + review_quality: f?.review_quality || null, + review_explanation: f?.review_explanation || null, + } + }, + ) + + return { events: eventsWithReviewData, totalCount } + } +} export const getAllOptimizationEvents = withTiming( "getAllOptimizationEvents",