mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
Merge pull request #2605 from codeflash-ai/fix/security-authorization-bugs
fix: close authorization bypass and data-integrity bugs
This commit is contained in:
commit
80d10762ff
17 changed files with 1083 additions and 872 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
import { prisma } from "@codeflash-ai/common"
|
import { prisma } from "@codeflash-ai/common"
|
||||||
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
vi.mock("@/lib/server-action-timing", () => ({
|
vi.mock("@/lib/server-action-timing", () => ({
|
||||||
withTiming: vi.fn((_name: string, fn: Function) => fn),
|
withTiming: vi.fn((_name: string, fn: Function) => fn),
|
||||||
|
|
@ -9,6 +10,10 @@ vi.mock("@/lib/analytics/tracking", () => ({
|
||||||
trackMemberInvited: vi.fn(),
|
trackMemberInvited: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/server/get-account-context", () => ({
|
||||||
|
getActionAccountContext: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockOrg = {
|
const mockOrg = {
|
||||||
id: "org-1",
|
id: "org-1",
|
||||||
organization_members: [
|
organization_members: [
|
||||||
|
|
@ -41,6 +46,13 @@ describe("getOrganizationMembers", () => {
|
||||||
let getOrganizationMembers: typeof import("../action").getOrganizationMembers
|
let getOrganizationMembers: typeof import("../action").getOrganizationMembers
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(getActionAccountContext).mockResolvedValue({
|
||||||
|
payload: { userId: "user-1", username: "testuser" },
|
||||||
|
userId: "user-1",
|
||||||
|
username: "testuser",
|
||||||
|
})
|
||||||
|
|
||||||
const mod = await import("../action")
|
const mod = await import("../action")
|
||||||
getOrganizationMembers = mod.getOrganizationMembers
|
getOrganizationMembers = mod.getOrganizationMembers
|
||||||
})
|
})
|
||||||
|
|
@ -50,7 +62,7 @@ describe("getOrganizationMembers", () => {
|
||||||
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
||||||
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue({ id: "member-1" } as any)
|
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue({ id: "member-1" } as any)
|
||||||
|
|
||||||
const result = await getOrganizationMembers("user-1", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.data).toHaveLength(2)
|
expect(result.data).toHaveLength(2)
|
||||||
|
|
@ -60,7 +72,7 @@ describe("getOrganizationMembers", () => {
|
||||||
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
||||||
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue({ id: "member-1" } as any)
|
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue({ id: "member-1" } as any)
|
||||||
|
|
||||||
const result = await getOrganizationMembers("user-1", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
const member = result.data![0]
|
const member = result.data![0]
|
||||||
|
|
||||||
expect(member).toEqual({
|
expect(member).toEqual({
|
||||||
|
|
@ -81,17 +93,32 @@ describe("getOrganizationMembers", () => {
|
||||||
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(null)
|
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(null)
|
||||||
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue(null)
|
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue(null)
|
||||||
|
|
||||||
const result = await getOrganizationMembers("user-1", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toBe("Organization not found")
|
expect(result.error).toBe("Organization not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("returns unauthorized when there is no active session", async () => {
|
||||||
|
vi.mocked(getActionAccountContext).mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Unauthorized")
|
||||||
|
expect(prisma.organizations.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it("returns error when user is not in organization members", async () => {
|
it("returns error when user is not in organization members", async () => {
|
||||||
|
vi.mocked(getActionAccountContext).mockResolvedValue({
|
||||||
|
payload: { userId: "unknown-user", username: "testuser" },
|
||||||
|
userId: "unknown-user",
|
||||||
|
username: "testuser",
|
||||||
|
})
|
||||||
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg as any)
|
||||||
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue(null)
|
vi.mocked(prisma.organization_members.findUnique).mockResolvedValue(null)
|
||||||
|
|
||||||
const result = await getOrganizationMembers("unknown-user", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toBe("You don't have access to this organization")
|
expect(result.error).toBe("You don't have access to this organization")
|
||||||
|
|
@ -102,7 +129,7 @@ describe("getOrganizationMembers", () => {
|
||||||
it("returns error response when Prisma throws", async () => {
|
it("returns error response when Prisma throws", async () => {
|
||||||
vi.mocked(prisma.organizations.findUnique).mockRejectedValue(new Error("Connection failed"))
|
vi.mocked(prisma.organizations.findUnique).mockRejectedValue(new Error("Connection failed"))
|
||||||
|
|
||||||
const result = await getOrganizationMembers("user-1", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toBe("Connection failed")
|
expect(result.error).toBe("Connection failed")
|
||||||
|
|
@ -111,7 +138,7 @@ describe("getOrganizationMembers", () => {
|
||||||
it("uses fallback message for non-Error exceptions", async () => {
|
it("uses fallback message for non-Error exceptions", async () => {
|
||||||
vi.mocked(prisma.organizations.findUnique).mockRejectedValue("string error")
|
vi.mocked(prisma.organizations.findUnique).mockRejectedValue("string error")
|
||||||
|
|
||||||
const result = await getOrganizationMembers("user-1", "org-1")
|
const result = await getOrganizationMembers("org-1")
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toBe("Failed to get members")
|
expect(result.error).toBe("Failed to get members")
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,27 @@ import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/li
|
||||||
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
|
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
|
||||||
import {
|
import {
|
||||||
deleteOrganizationMemberApiKeys,
|
deleteOrganizationMemberApiKeys,
|
||||||
getUserById,
|
|
||||||
organizationMemberRepository,
|
organizationMemberRepository,
|
||||||
prisma,
|
prisma,
|
||||||
} from "@codeflash-ai/common"
|
} from "@codeflash-ai/common"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { withTiming } from "@/lib/server-action-timing"
|
import { withTiming } from "@/lib/server-action-timing"
|
||||||
import { trackMemberInvited } from "@/lib/analytics/tracking"
|
import { trackMemberInvited } from "@/lib/analytics/tracking"
|
||||||
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get organization members
|
* Get organization members
|
||||||
*/
|
*/
|
||||||
export const getOrganizationMembers = withTiming(
|
export const getOrganizationMembers = withTiming(
|
||||||
"getOrganizationMembers",
|
"getOrganizationMembers",
|
||||||
async (currentUserId: string, organizationId: string): Promise<ActionResponse<Member[]>> => {
|
async (organizationId: string): Promise<ActionResponse<Member[]>> => {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check access via indexed composite key in parallel with member fetch
|
// Check access via indexed composite key in parallel with member fetch
|
||||||
const [org, accessCheck] = await Promise.all([
|
const [org, accessCheck] = await Promise.all([
|
||||||
|
|
@ -86,11 +94,17 @@ export const getOrganizationMembers = withTiming(
|
||||||
* Add a member to organization
|
* Add a member to organization
|
||||||
*/
|
*/
|
||||||
export async function addOrganizationMember(
|
export async function addOrganizationMember(
|
||||||
currentUserId: string,
|
|
||||||
invitedUser: GitHubUserSearchResult,
|
invitedUser: GitHubUserSearchResult,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<ActionResponse<Member>> {
|
): Promise<ActionResponse<Member>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
|
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
|
||||||
|
|
||||||
|
|
@ -131,31 +145,35 @@ export async function addOrganizationMember(
|
||||||
return createErrorResponse("User is already a member of this organization")
|
return createErrorResponse("User is already a member of this organization")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists in our database
|
// Ensure user exists in our database (upsert handles concurrent invites safely)
|
||||||
let user = await getUserById(invitedUserId)
|
const user = await prisma.users.upsert({
|
||||||
|
where: { user_id: invitedUserId },
|
||||||
// If user doesn't exist, create them and re-fetch for consistent types
|
update: {},
|
||||||
if (!user) {
|
create: {
|
||||||
await prisma.users.create({
|
user_id: invitedUserId,
|
||||||
data: {
|
github_username: invitedUser.username,
|
||||||
user_id: invitedUserId,
|
onboarding_completed: false,
|
||||||
github_username: invitedUser.username,
|
|
||||||
onboarding_completed: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
user = await getUserById(invitedUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user to organization members
|
|
||||||
const newMember = await prisma.organization_members.create({
|
|
||||||
data: {
|
|
||||||
organization_id: organizationId,
|
|
||||||
user_id: user.user_id,
|
|
||||||
role,
|
|
||||||
added_by: currentUserId,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let newMember
|
||||||
|
try {
|
||||||
|
// Add user to organization members
|
||||||
|
newMember = await prisma.organization_members.create({
|
||||||
|
data: {
|
||||||
|
organization_id: organizationId,
|
||||||
|
user_id: user.user_id,
|
||||||
|
role,
|
||||||
|
added_by: currentUserId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
|
return createErrorResponse("User is already a member of this organization")
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
trackMemberInvited(currentUserId, {
|
trackMemberInvited(currentUserId, {
|
||||||
invitedUsername: invitedUser.username,
|
invitedUsername: invitedUser.username,
|
||||||
role,
|
role,
|
||||||
|
|
@ -186,11 +204,17 @@ export async function addOrganizationMember(
|
||||||
* Update organization member role
|
* Update organization member role
|
||||||
*/
|
*/
|
||||||
export async function updateOrganizationMemberRole(
|
export async function updateOrganizationMemberRole(
|
||||||
currentUserId: string,
|
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
newRole: "admin" | "member" | "owner",
|
newRole: "admin" | "member" | "owner",
|
||||||
): Promise<ActionResponse<Boolean>> {
|
): Promise<ActionResponse<Boolean>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch only the two specific members we need instead of loading ALL members
|
// Fetch only the two specific members we need instead of loading ALL members
|
||||||
const [currentUserMember, targetMember] = await Promise.all([
|
const [currentUserMember, targetMember] = await Promise.all([
|
||||||
|
|
@ -200,8 +224,8 @@ export async function updateOrganizationMemberRole(
|
||||||
},
|
},
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
}),
|
}),
|
||||||
prisma.organization_members.findUnique({
|
prisma.organization_members.findFirst({
|
||||||
where: { id: memberId },
|
where: { id: memberId, organization_id: organizationId },
|
||||||
select: { id: true, role: true, user_id: true },
|
select: { id: true, role: true, user_id: true },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
@ -210,17 +234,21 @@ export async function updateOrganizationMemberRole(
|
||||||
return createErrorResponse("Organization not found")
|
return createErrorResponse("Organization not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
return createErrorResponse("Member not found in this organization")
|
||||||
|
}
|
||||||
|
|
||||||
// Only admins and owners can change roles
|
// Only admins and owners can change roles
|
||||||
if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") {
|
if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") {
|
||||||
return createErrorResponse("Only admins can change member roles")
|
return createErrorResponse("Only admins can change member roles")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow changing owner role
|
// Don't allow changing owner role
|
||||||
if (targetMember?.role === "owner") {
|
if (targetMember.role === "owner") {
|
||||||
return createErrorResponse("Cannot change owner role")
|
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")
|
return createErrorResponse("Cannot change your own role as the only admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,10 +268,16 @@ export async function updateOrganizationMemberRole(
|
||||||
* Remove organization member
|
* Remove organization member
|
||||||
*/
|
*/
|
||||||
export async function removeOrganizationMember(
|
export async function removeOrganizationMember(
|
||||||
currentUserId: string,
|
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
): Promise<ActionResponse<Boolean>> {
|
): Promise<ActionResponse<Boolean>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch only the two specific members we need instead of loading ALL members
|
// Fetch only the two specific members we need instead of loading ALL members
|
||||||
const [currentUserMember, targetMember] = await Promise.all([
|
const [currentUserMember, targetMember] = await Promise.all([
|
||||||
|
|
@ -253,14 +287,14 @@ export async function removeOrganizationMember(
|
||||||
},
|
},
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
}),
|
}),
|
||||||
prisma.organization_members.findUnique({
|
prisma.organization_members.findFirst({
|
||||||
where: { id: memberId },
|
where: { id: memberId, organization_id: organizationId },
|
||||||
select: { id: true, role: true, user_id: true },
|
select: { id: true, role: true, user_id: true },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!targetMember) {
|
if (!targetMember) {
|
||||||
return createErrorResponse("Member not found")
|
return createErrorResponse("Member not found in this organization")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cannot remove owner
|
// Cannot remove owner
|
||||||
|
|
@ -296,9 +330,15 @@ export async function removeOrganizationMember(
|
||||||
* Get current user's role in organization
|
* Get current user's role in organization
|
||||||
*/
|
*/
|
||||||
export async function getCurrentUserRole(
|
export async function getCurrentUserRole(
|
||||||
userId: string,
|
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<ActionResponse<{ role: UserRole }>> {
|
): Promise<ActionResponse<{ role: UserRole }>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const member = await prisma.organization_members.findUnique({
|
const member = await prisma.organization_members.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { auth0 } from "@/lib/auth0"
|
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import type { Member, UserRole } from "@/lib/types"
|
import type { Member, UserRole } from "@/lib/types"
|
||||||
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side function to fetch all data needed for the members page in parallel.
|
* Server-side function to fetch all data needed for the members page in parallel.
|
||||||
* Uses @/lib/prisma directly to avoid pulling in @codeflash-ai/common at build time.
|
* Uses @/lib/prisma directly to avoid pulling in @codeflash-ai/common at build time.
|
||||||
*/
|
*/
|
||||||
export async function getMembersPageInitData() {
|
export async function getMembersPageInitData() {
|
||||||
const session = await auth0.getSession()
|
const accountContext = await getActionAccountContext()
|
||||||
if (!session?.user?.sub) {
|
if (!accountContext) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.sub
|
const { payload, userId } = accountContext
|
||||||
|
const orgId = "orgId" in payload ? payload.orgId : null
|
||||||
const cookieStore = await cookies()
|
|
||||||
const orgId = cookieStore.get("currentOrganizationId")?.value
|
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return { userId, orgId: null, members: [] as Member[], currentUserRole: null }
|
return { userId, orgId: null, members: [] as Member[], currentUserRole: null }
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@ import { MembersSkeleton } from "@/components/members/MembersSkeleton"
|
||||||
import { GitHubUserSearchResult, Member } from "@/lib/types"
|
import { GitHubUserSearchResult, Member } from "@/lib/types"
|
||||||
import {
|
import {
|
||||||
addOrganizationMember,
|
addOrganizationMember,
|
||||||
getCurrentUserRole,
|
|
||||||
getOrganizationMembers,
|
|
||||||
updateOrganizationMemberRole,
|
updateOrganizationMemberRole,
|
||||||
removeOrganizationMember,
|
removeOrganizationMember,
|
||||||
} from "./action"
|
} from "./action"
|
||||||
import { useViewMode } from "@/app/app/ViewModeContext"
|
import { useViewMode } from "@/app/app/ViewModeContext"
|
||||||
import { MembersList } from "@/components/members/members-list"
|
import { MembersList } from "@/components/members/members-list"
|
||||||
import { UserSearchModal } from "@/components/members/user-search-modal"
|
import { UserSearchModal } from "@/components/members/user-search-modal"
|
||||||
|
import { getMembersPageInitData } from "./data"
|
||||||
|
|
||||||
export interface MembersClientProps {
|
export interface MembersClientProps {
|
||||||
initialUserId: string
|
initialUserId: string
|
||||||
|
|
@ -65,18 +64,15 @@ export function MembersClient({
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [roleResult, result] = await Promise.all([
|
const initData = await getMembersPageInitData()
|
||||||
getCurrentUserRole(currentUserId, currentOrg.id),
|
|
||||||
getOrganizationMembers(currentUserId, currentOrg.id),
|
|
||||||
])
|
|
||||||
if (roleResult.success && roleResult.data) {
|
|
||||||
setCurrentUserRole(roleResult.data.role)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (!initData || initData.orgId !== currentOrg.id) {
|
||||||
setMembers(result.data)
|
setCurrentUserRole(null)
|
||||||
|
setMembers([])
|
||||||
|
setError("Failed to load members")
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "Failed to load members")
|
setCurrentUserRole(initData.currentUserRole)
|
||||||
|
setMembers(initData.members)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch members:", err)
|
console.error("Failed to fetch members:", err)
|
||||||
|
|
@ -85,7 +81,7 @@ export function MembersClient({
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [currentOrg?.id, currentUserId, isRefreshing])
|
}, [currentOrg?.id, isRefreshing])
|
||||||
|
|
||||||
// Only refetch when org changes from what the server provided
|
// Only refetch when org changes from what the server provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -118,7 +114,7 @@ export function MembersClient({
|
||||||
return { success: false, error: "No organization selected" }
|
return { success: false, error: "No organization selected" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await addOrganizationMember(currentUserId, user, role, currentOrg.id)
|
const result = await addOrganizationMember(user, role, currentOrg.id)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
handleMemberAdded()
|
handleMemberAdded()
|
||||||
}
|
}
|
||||||
|
|
@ -132,12 +128,7 @@ export function MembersClient({
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
const result = await updateOrganizationMemberRole(
|
const result = await updateOrganizationMemberRole(currentOrg.id, memberId, newRole)
|
||||||
currentUserId,
|
|
||||||
currentOrg.id,
|
|
||||||
memberId,
|
|
||||||
newRole,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess("Member role updated successfully")
|
setSuccess("Member role updated successfully")
|
||||||
|
|
@ -164,7 +155,7 @@ export function MembersClient({
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
const result = await removeOrganizationMember(currentUserId, currentOrg.id, memberId)
|
const result = await removeOrganizationMember(currentOrg.id, memberId)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess(`${memberUsername} has been removed successfully`)
|
setSuccess(`${memberUsername} has been removed successfully`)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
import { prisma } from "@codeflash-ai/common"
|
import { prisma } from "@codeflash-ai/common"
|
||||||
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
|
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
|
||||||
import { trackRepositoryConnected } from "@/lib/analytics/tracking"
|
import { trackRepositoryConnected } from "@/lib/analytics/tracking"
|
||||||
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
vi.mock("@/lib/server-action-timing", () => ({
|
vi.mock("@/lib/server-action-timing", () => ({
|
||||||
withTiming: vi.fn((_name: string, fn: Function) => fn),
|
withTiming: vi.fn((_name: string, fn: Function) => fn),
|
||||||
|
|
@ -16,6 +17,10 @@ vi.mock("@/lib/analytics/tracking", () => ({
|
||||||
trackRepositoryConnected: vi.fn(),
|
trackRepositoryConnected: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/server/get-account-context", () => ({
|
||||||
|
getActionAccountContext: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockRepo = {
|
const mockRepo = {
|
||||||
id: "repo-1",
|
id: "repo-1",
|
||||||
github_repo_id: "12345",
|
github_repo_id: "12345",
|
||||||
|
|
@ -35,11 +40,12 @@ const mockRepo = {
|
||||||
const mockPayload = { userId: "user-1", username: "testuser" }
|
const mockPayload = { userId: "user-1", username: "testuser" }
|
||||||
|
|
||||||
describe("getRepositoryById", () => {
|
describe("getRepositoryById", () => {
|
||||||
let getRepositoryById: typeof import("../action").getRepositoryById
|
let getRepositoryById: typeof import("../queries").getRepositoryByIdForPayload
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mod = await import("../action")
|
vi.clearAllMocks()
|
||||||
getRepositoryById = mod.getRepositoryById
|
const mod = await import("../queries")
|
||||||
|
getRepositoryById = mod.getRepositoryByIdForPayload
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("parallel fetch", () => {
|
describe("parallel fetch", () => {
|
||||||
|
|
@ -161,3 +167,51 @@ describe("getRepositoryById", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("getRepositoryMembers", () => {
|
||||||
|
let getRepositoryMembers: typeof import("../action").getRepositoryMembers
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(getActionAccountContext).mockResolvedValue({
|
||||||
|
payload: mockPayload,
|
||||||
|
userId: "user-1",
|
||||||
|
username: "testuser",
|
||||||
|
})
|
||||||
|
;(prisma.repository_members as any).findMany = vi.fn()
|
||||||
|
|
||||||
|
const mod = await import("../action")
|
||||||
|
getRepositoryMembers = mod.getRepositoryMembers
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the authenticated session user for the access check", async () => {
|
||||||
|
vi.mocked(prisma.repository_members.findUnique).mockResolvedValue({ id: "member-1" } as any)
|
||||||
|
vi.mocked(prisma.repository_members.findMany).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "member-1",
|
||||||
|
user_id: "user-1",
|
||||||
|
role: "admin",
|
||||||
|
added_at: new Date("2024-01-01"),
|
||||||
|
user: { github_username: "alice" },
|
||||||
|
},
|
||||||
|
] as any)
|
||||||
|
|
||||||
|
const result = await getRepositoryMembers("repo-1")
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(prisma.repository_members.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { repository_id_user_id: { repository_id: "repo-1", user_id: "user-1" } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns unauthorized when there is no active session", async () => {
|
||||||
|
vi.mocked(getActionAccountContext).mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await getRepositoryMembers("repo-1")
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("Unauthorized")
|
||||||
|
expect(prisma.repository_members.findUnique).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,237 +1,25 @@
|
||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs"
|
import * as Sentry from "@sentry/nextjs"
|
||||||
import { AccountPayload, createOrUpdateUser, getUserById, prisma } from "@codeflash-ai/common"
|
import { createOrUpdateUser, getUserById, prisma } from "@codeflash-ai/common"
|
||||||
import { Prisma } from "@prisma/client"
|
import { Prisma } from "@prisma/client"
|
||||||
import { eachDayOfInterval, startOfDay } from "date-fns"
|
|
||||||
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
|
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
|
||||||
import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/lib/action-response"
|
import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/lib/action-response"
|
||||||
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
|
import { trackMemberInvited } from "@/lib/analytics/tracking"
|
||||||
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
import { withTiming } from "@/lib/server-action-timing"
|
|
||||||
import { trackMemberInvited, trackRepositoryConnected } from "@/lib/analytics/tracking"
|
|
||||||
|
|
||||||
export async function getOptimizationsTimeSeriesData(repoId: string, onlySuccessful?: boolean) {
|
|
||||||
try {
|
|
||||||
// Use SQL GROUP BY to aggregate on the database side instead of fetching every row
|
|
||||||
const successFilter =
|
|
||||||
onlySuccessful === true ? Prisma.sql`AND is_optimization_found = true` : Prisma.empty
|
|
||||||
const dailyCounts = await prisma.$queryRaw<Array<{ day: string; cnt: bigint }>>`
|
|
||||||
SELECT DATE(created_at) AS day, COUNT(*)::bigint AS cnt
|
|
||||||
FROM optimization_events
|
|
||||||
WHERE repository_id = ${repoId} ${successFilter}
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY day`
|
|
||||||
|
|
||||||
if (dailyCounts.length === 0) return []
|
|
||||||
|
|
||||||
const groupedByDay: Record<string, number> = {}
|
|
||||||
for (const row of dailyCounts) {
|
|
||||||
// DATE columns come back as Date objects from Prisma; format to YYYY-MM-DD
|
|
||||||
const dayStr =
|
|
||||||
typeof row.day === "string"
|
|
||||||
? row.day
|
|
||||||
: (row.day as unknown as Date).toISOString().slice(0, 10)
|
|
||||||
groupedByDay[dayStr] = Number(row.cnt)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedDays = Object.keys(groupedByDay).sort()
|
|
||||||
const allDates = eachDayOfInterval({
|
|
||||||
start: new Date(sortedDays[0] + "T00:00:00"),
|
|
||||||
end: startOfDay(new Date()),
|
|
||||||
}).map(d => d.toISOString().slice(0, 10))
|
|
||||||
|
|
||||||
let cumulativeCount = 0
|
|
||||||
const completeData = allDates.map(date => {
|
|
||||||
cumulativeCount += groupedByDay[date] || 0
|
|
||||||
return { date, count: cumulativeCount }
|
|
||||||
})
|
|
||||||
|
|
||||||
return completeData
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch optimization time series data:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPullRequestEventTimeSeriesData(year: number, repoId: string) {
|
|
||||||
try {
|
|
||||||
// Use SQL GROUP BY to aggregate on the database side instead of fetching every row
|
|
||||||
const startDate = new Date(`${year}-01-01T00:00:00.000Z`)
|
|
||||||
const endDate = new Date(`${year + 1}-01-01T00:00:00.000Z`)
|
|
||||||
const monthlyStats = await prisma.$queryRaw<
|
|
||||||
Array<{
|
|
||||||
month: number
|
|
||||||
pr_created: bigint
|
|
||||||
pr_merged: bigint
|
|
||||||
pr_closed: bigint
|
|
||||||
}>
|
|
||||||
>`SELECT
|
|
||||||
EXTRACT(MONTH FROM created_at)::int AS month,
|
|
||||||
SUM(CASE WHEN event_type = 'pr_created' THEN 1 ELSE 0 END)::bigint AS pr_created,
|
|
||||||
SUM(CASE WHEN event_type = 'pr_merged' THEN 1 ELSE 0 END)::bigint AS pr_merged,
|
|
||||||
SUM(CASE WHEN event_type = 'pr_closed' THEN 1 ELSE 0 END)::bigint AS pr_closed
|
|
||||||
FROM optimization_events
|
|
||||||
WHERE event_type IN ('pr_created', 'pr_merged', 'pr_closed')
|
|
||||||
AND created_at >= ${startDate}
|
|
||||||
AND created_at < ${endDate}
|
|
||||||
AND repository_id = ${repoId}
|
|
||||||
GROUP BY EXTRACT(MONTH FROM created_at)`
|
|
||||||
|
|
||||||
type MonthStat = { month: number; pr_created: bigint; pr_merged: bigint; pr_closed: bigint }
|
|
||||||
const statsMap = new Map<number, MonthStat>(
|
|
||||||
(monthlyStats as MonthStat[]).map((r: MonthStat) => [r.month, r]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Array.from({ length: 12 }, (_, i) => {
|
|
||||||
const month = i + 1
|
|
||||||
const stats = statsMap.get(month)
|
|
||||||
return {
|
|
||||||
month: `${year}-${month.toString().padStart(2, "0")}`,
|
|
||||||
pr_created: Number(stats?.pr_created ?? 0),
|
|
||||||
pr_merged: Number(stats?.pr_merged ?? 0),
|
|
||||||
pr_closed: Number(stats?.pr_closed ?? 0),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch pull request event time series data:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserOptimizationCountByRepo(repoId: string) {
|
|
||||||
return prisma.optimization_events.count({
|
|
||||||
where: {
|
|
||||||
repository_id: repoId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserOptimizationSuccessfulCountByRepo(repoId: string) {
|
|
||||||
return prisma.optimization_events.count({
|
|
||||||
where: {
|
|
||||||
is_optimization_found: true,
|
|
||||||
repository_id: repoId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get both total and successful optimization counts in a single query.
|
|
||||||
* Callers that need both counts should prefer this over two separate calls.
|
|
||||||
*/
|
|
||||||
export async function getOptimizationCountsByRepo(
|
|
||||||
repoId: string,
|
|
||||||
): Promise<{ total: number; successful: number }> {
|
|
||||||
const result = await prisma.$queryRaw<[{ total: bigint; successful: bigint }]>`
|
|
||||||
SELECT
|
|
||||||
COUNT(*)::bigint AS total,
|
|
||||||
SUM(CASE WHEN is_optimization_found THEN 1 ELSE 0 END)::bigint AS successful
|
|
||||||
FROM optimization_events
|
|
||||||
WHERE repository_id = ${repoId}`
|
|
||||||
return {
|
|
||||||
total: Number(result[0].total),
|
|
||||||
successful: Number(result[0].successful),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveUserLeaderboardLast30DaysForRepo(
|
|
||||||
repoId: string,
|
|
||||||
): Promise<{ username: string; eventCount: number; avatarUrl: string }[]> {
|
|
||||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
const groupedCounts = await prisma.optimization_events.groupBy({
|
|
||||||
by: ["current_username"],
|
|
||||||
where: {
|
|
||||||
repository_id: repoId,
|
|
||||||
created_at: {
|
|
||||||
gte: since,
|
|
||||||
},
|
|
||||||
current_username: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
_count: {
|
|
||||||
id: "desc",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return groupedCounts.map(
|
|
||||||
(entry: { current_username: string | null; _count: { id: number } }) => ({
|
|
||||||
username: entry.current_username!,
|
|
||||||
eventCount: entry._count.id,
|
|
||||||
avatarUrl: `https://github.com/${entry.current_username}.png`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRepositoryById = withTiming(
|
|
||||||
"getRepositoryById",
|
|
||||||
async (payload: AccountPayload, repoId: string): Promise<RepositoryWithUsage | null> => {
|
|
||||||
try {
|
|
||||||
// Fetch repo, authorized repoIds, and recent activity count in parallel
|
|
||||||
const [repo, { repoIds }, recentEventCount] = await Promise.all([
|
|
||||||
prisma.repositories.findUnique({
|
|
||||||
where: { id: repoId },
|
|
||||||
include: { _count: { select: { repository_members: true } } },
|
|
||||||
}),
|
|
||||||
getRepositoriesForAccountCached(payload),
|
|
||||||
prisma.optimization_events.count({
|
|
||||||
where: {
|
|
||||||
repository_id: repoId,
|
|
||||||
created_at: {
|
|
||||||
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!repo || !repoIds.includes(repo.id)) return null
|
|
||||||
|
|
||||||
// Track repository view as a connection/engagement signal
|
|
||||||
const userId = "userId" in payload ? payload.userId : undefined
|
|
||||||
if (userId) {
|
|
||||||
trackRepositoryConnected(userId, {
|
|
||||||
repositoryId: repo.id,
|
|
||||||
repositoryName: repo.full_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const organization = repo.full_name.split("/")[0]
|
|
||||||
return {
|
|
||||||
id: repo.id,
|
|
||||||
github_repo_id: repo.github_repo_id,
|
|
||||||
name: repo.name,
|
|
||||||
full_name: repo.full_name,
|
|
||||||
is_private: repo.is_private,
|
|
||||||
is_active: recentEventCount > 0,
|
|
||||||
has_github_action: repo.has_github_action,
|
|
||||||
created_at: repo.created_at,
|
|
||||||
last_optimized: repo.last_optimized,
|
|
||||||
optimizations_limit: repo.optimizations_limit,
|
|
||||||
optimizations_used: repo.optimizations_used,
|
|
||||||
organization,
|
|
||||||
avatarUrl: `https://github.com/${organization}.png`,
|
|
||||||
membersCount: repo._count.repository_members,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch repository by ID:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function addRepositoryMemberById(
|
export async function addRepositoryMemberById(
|
||||||
currentUserId: string,
|
|
||||||
repoId: string,
|
repoId: string,
|
||||||
invitedUser: GitHubUserSearchResult,
|
invitedUser: GitHubUserSearchResult,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
): Promise<ActionResponse> {
|
): Promise<ActionResponse> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
|
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
|
||||||
|
|
||||||
|
|
@ -280,15 +68,23 @@ export async function addRepositoryMemberById(
|
||||||
user = await createOrUpdateUser(invitedUserId, invitedUser.username, null, null)
|
user = await createOrUpdateUser(invitedUserId, invitedUser.username, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user to repository members
|
let newMember
|
||||||
const newMember = await prisma.repository_members.create({
|
try {
|
||||||
data: {
|
// Add user to repository members
|
||||||
repository_id: repoId,
|
newMember = await prisma.repository_members.create({
|
||||||
user_id: user.user_id,
|
data: {
|
||||||
role,
|
repository_id: repoId,
|
||||||
added_by: currentUserId,
|
user_id: user.user_id,
|
||||||
},
|
role,
|
||||||
})
|
added_by: currentUserId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
|
return createErrorResponse("User is already a member of this repository")
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
trackMemberInvited(currentUserId, {
|
trackMemberInvited(currentUserId, {
|
||||||
invitedUsername: invitedUser.username,
|
invitedUsername: invitedUser.username,
|
||||||
|
|
@ -316,10 +112,14 @@ export async function addRepositoryMemberById(
|
||||||
/**
|
/**
|
||||||
* Get repository members
|
* Get repository members
|
||||||
*/
|
*/
|
||||||
export async function getRepositoryMembers(
|
export async function getRepositoryMembers(repoId: string): Promise<ActionResponse<Member[]>> {
|
||||||
currentUserId: string,
|
const accountContext = await getActionAccountContext()
|
||||||
repoId: string,
|
if (!accountContext) {
|
||||||
): Promise<ActionResponse<Member[]>> {
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check access with a single indexed lookup, then fetch members only if authorized
|
// Check access with a single indexed lookup, then fetch members only if authorized
|
||||||
const hasAccess = await prisma.repository_members.findUnique({
|
const hasAccess = await prisma.repository_members.findUnique({
|
||||||
|
|
@ -372,11 +172,17 @@ export async function getRepositoryMembers(
|
||||||
* Update repository member role
|
* Update repository member role
|
||||||
*/
|
*/
|
||||||
export async function updateRepositoryMemberRole(
|
export async function updateRepositoryMemberRole(
|
||||||
currentUserId: string,
|
|
||||||
repoId: string,
|
repoId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
newRole: UserRole,
|
newRole: UserRole,
|
||||||
): Promise<ActionResponse<Boolean>> {
|
): Promise<ActionResponse<Boolean>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch only the two specific members we need instead of loading ALL repository members
|
// Fetch only the two specific members we need instead of loading ALL repository members
|
||||||
const [currentUserMember, targetMember] = await Promise.all([
|
const [currentUserMember, targetMember] = await Promise.all([
|
||||||
|
|
@ -384,8 +190,8 @@ export async function updateRepositoryMemberRole(
|
||||||
where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } },
|
where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } },
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
}),
|
}),
|
||||||
prisma.repository_members.findUnique({
|
prisma.repository_members.findFirst({
|
||||||
where: { id: memberId },
|
where: { id: memberId, repository_id: repoId },
|
||||||
select: { id: true, role: true, user_id: true },
|
select: { id: true, role: true, user_id: true },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
@ -394,17 +200,21 @@ export async function updateRepositoryMemberRole(
|
||||||
return createErrorResponse("Repository not found")
|
return createErrorResponse("Repository not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
return createErrorResponse("Member not found in this repository")
|
||||||
|
}
|
||||||
|
|
||||||
// Only admins and owners can change roles
|
// Only admins and owners can change roles
|
||||||
if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") {
|
if (currentUserMember.role !== "admin" && currentUserMember.role !== "owner") {
|
||||||
return createErrorResponse("Only admins can change member roles")
|
return createErrorResponse("Only admins can change member roles")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow changing owner role
|
// Don't allow changing owner role
|
||||||
if (targetMember?.role === "owner") {
|
if (targetMember.role === "owner") {
|
||||||
return createErrorResponse("Cannot change owner role")
|
return createErrorResponse("Cannot change owner role")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetMember?.user_id === currentUserId) {
|
if (targetMember.user_id === currentUserId) {
|
||||||
return createErrorResponse("Cannot change your own role")
|
return createErrorResponse("Cannot change your own role")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,10 +235,16 @@ export async function updateRepositoryMemberRole(
|
||||||
* Remove repository member
|
* Remove repository member
|
||||||
*/
|
*/
|
||||||
export async function removeRepositoryMember(
|
export async function removeRepositoryMember(
|
||||||
currentUserId: string,
|
|
||||||
repoId: string,
|
repoId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
): Promise<ActionResponse<Boolean>> {
|
): Promise<ActionResponse<Boolean>> {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId: currentUserId } = accountContext
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch only the two specific members we need instead of loading ALL repository members
|
// Fetch only the two specific members we need instead of loading ALL repository members
|
||||||
const [currentUserMember, targetMember] = await Promise.all([
|
const [currentUserMember, targetMember] = await Promise.all([
|
||||||
|
|
@ -436,14 +252,14 @@ export async function removeRepositoryMember(
|
||||||
where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } },
|
where: { repository_id_user_id: { repository_id: repoId, user_id: currentUserId } },
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
}),
|
}),
|
||||||
prisma.repository_members.findUnique({
|
prisma.repository_members.findFirst({
|
||||||
where: { id: memberId },
|
where: { id: memberId, repository_id: repoId },
|
||||||
select: { id: true, role: true, user_id: true },
|
select: { id: true, role: true, user_id: true },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!targetMember) {
|
if (!targetMember) {
|
||||||
return createErrorResponse("Member not found")
|
return createErrorResponse("Member not found in this repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cannot remove owner
|
// Cannot remove owner
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,32 @@
|
||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { auth0 } from "@/lib/auth0"
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import type { AccountPayload } from "@codeflash-ai/common"
|
|
||||||
import {
|
import {
|
||||||
getRepositoryById,
|
getActiveUserLeaderboardLast30DaysForRepo,
|
||||||
getOptimizationCountsByRepo,
|
getOptimizationCountsByRepo,
|
||||||
getOptimizationsTimeSeriesData,
|
getOptimizationsTimeSeriesData,
|
||||||
getPullRequestEventTimeSeriesData,
|
getPullRequestEventTimeSeriesData,
|
||||||
getActiveUserLeaderboardLast30DaysForRepo,
|
getRepositoryByIdForPayload,
|
||||||
} from "./action"
|
} from "./queries"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side function to fetch all data needed for the repository detail page
|
* Server-side function to fetch all data needed for the repository detail page
|
||||||
* in parallel. Eliminates the client-side auth→repo→stats waterfall.
|
* in parallel. Eliminates the client-side auth→repo→stats waterfall.
|
||||||
*/
|
*/
|
||||||
export async function getRepoDetailInitData(repositoryId: string) {
|
export async function getRepoDetailInitData(repositoryId: string, selectedPrYear?: number) {
|
||||||
const session = await auth0.getSession()
|
const accountContext = await getActionAccountContext()
|
||||||
if (!session?.user?.sub || !session?.user?.nickname) {
|
if (!accountContext) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.sub
|
const { payload, userId } = accountContext
|
||||||
const username = session.user.nickname
|
|
||||||
|
|
||||||
const cookieStore = await cookies()
|
const repository = await getRepositoryByIdForPayload(payload, repositoryId)
|
||||||
const orgId = cookieStore.get("currentOrganizationId")?.value
|
|
||||||
|
|
||||||
const payload: AccountPayload = orgId ? { orgId } : { userId, username }
|
|
||||||
|
|
||||||
const repository = await getRepositoryById(payload, repositoryId)
|
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
return { userId, repository: null, stats: null }
|
return { userId, repository: null, stats: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear()
|
const prYear = selectedPrYear ?? new Date().getFullYear()
|
||||||
|
|
||||||
// Fetch all statistics in parallel — these are all independent queries
|
// Fetch all statistics in parallel — these are all independent queries
|
||||||
// Use the combined count query (single SQL) instead of two separate COUNT calls
|
// Use the combined count query (single SQL) instead of two separate COUNT calls
|
||||||
|
|
@ -43,7 +35,7 @@ export async function getRepoDetailInitData(repositoryId: string) {
|
||||||
getOptimizationCountsByRepo(repositoryId),
|
getOptimizationCountsByRepo(repositoryId),
|
||||||
getOptimizationsTimeSeriesData(repositoryId, false),
|
getOptimizationsTimeSeriesData(repositoryId, false),
|
||||||
getOptimizationsTimeSeriesData(repositoryId, true),
|
getOptimizationsTimeSeriesData(repositoryId, true),
|
||||||
getPullRequestEventTimeSeriesData(currentYear, repositoryId),
|
getPullRequestEventTimeSeriesData(prYear, repositoryId),
|
||||||
getActiveUserLeaderboardLast30DaysForRepo(repositoryId),
|
getActiveUserLeaderboardLast30DaysForRepo(repositoryId),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -72,7 +64,7 @@ export async function getRepoDetailInitData(repositoryId: string) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
orgId: orgId ?? null,
|
orgId: "orgId" in payload ? payload.orgId : null,
|
||||||
repository,
|
repository,
|
||||||
stats: {
|
stats: {
|
||||||
totalAttempts: totalAttempts ?? 0,
|
totalAttempts: totalAttempts ?? 0,
|
||||||
|
|
@ -83,7 +75,22 @@ export async function getRepoDetailInitData(repositoryId: string) {
|
||||||
successfulOptimizationsTrendDates,
|
successfulOptimizationsTrendDates,
|
||||||
prActivityData: Array.isArray(prData) ? prData : [],
|
prActivityData: Array.isArray(prData) ? prData : [],
|
||||||
activeUsersData: Array.isArray(leaderboardData) ? leaderboardData : [],
|
activeUsersData: Array.isArray(leaderboardData) ? leaderboardData : [],
|
||||||
prYear: currentYear,
|
prYear,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRepoDetailPrActivityData(repositoryId: string, year: number) {
|
||||||
|
const accountContext = await getActionAccountContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = await getRepositoryByIdForPayload(accountContext.payload, repositoryId)
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPullRequestEventTimeSeriesData(year, repositoryId)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { AccountPayload, prisma } from "@codeflash-ai/common"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
import * as Sentry from "@sentry/nextjs"
|
||||||
|
import { eachDayOfInterval, startOfDay } from "date-fns"
|
||||||
|
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
|
||||||
|
import { trackRepositoryConnected } from "@/lib/analytics/tracking"
|
||||||
|
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
|
||||||
|
import { withTiming } from "@/lib/server-action-timing"
|
||||||
|
|
||||||
|
export async function getOptimizationsTimeSeriesData(repoId: string, onlySuccessful?: boolean) {
|
||||||
|
try {
|
||||||
|
// Use SQL GROUP BY to aggregate on the database side instead of fetching every row
|
||||||
|
const successFilter =
|
||||||
|
onlySuccessful === true ? Prisma.sql`AND is_optimization_found = true` : Prisma.empty
|
||||||
|
const dailyCounts = await prisma.$queryRaw<Array<{ day: string; cnt: bigint }>>`
|
||||||
|
SELECT DATE(created_at) AS day, COUNT(*)::bigint AS cnt
|
||||||
|
FROM optimization_events
|
||||||
|
WHERE repository_id = ${repoId} ${successFilter}
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY day`
|
||||||
|
|
||||||
|
if (dailyCounts.length === 0) return []
|
||||||
|
|
||||||
|
const groupedByDay: Record<string, number> = {}
|
||||||
|
for (const row of dailyCounts) {
|
||||||
|
// DATE columns come back as Date objects from Prisma; format to YYYY-MM-DD
|
||||||
|
const dayStr =
|
||||||
|
typeof row.day === "string"
|
||||||
|
? row.day
|
||||||
|
: (row.day as unknown as Date).toISOString().slice(0, 10)
|
||||||
|
groupedByDay[dayStr] = Number(row.cnt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDays = Object.keys(groupedByDay).sort()
|
||||||
|
const allDates = eachDayOfInterval({
|
||||||
|
start: new Date(sortedDays[0] + "T00:00:00"),
|
||||||
|
end: startOfDay(new Date()),
|
||||||
|
}).map(d => d.toISOString().slice(0, 10))
|
||||||
|
|
||||||
|
let cumulativeCount = 0
|
||||||
|
const completeData = allDates.map(date => {
|
||||||
|
cumulativeCount += groupedByDay[date] || 0
|
||||||
|
return { date, count: cumulativeCount }
|
||||||
|
})
|
||||||
|
|
||||||
|
return completeData
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch optimization time series data:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPullRequestEventTimeSeriesData(year: number, repoId: string) {
|
||||||
|
try {
|
||||||
|
// Use SQL GROUP BY to aggregate on the database side instead of fetching every row
|
||||||
|
const startDate = new Date(`${year}-01-01T00:00:00.000Z`)
|
||||||
|
const endDate = new Date(`${year + 1}-01-01T00:00:00.000Z`)
|
||||||
|
const monthlyStats = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
month: number
|
||||||
|
pr_created: bigint
|
||||||
|
pr_merged: bigint
|
||||||
|
pr_closed: bigint
|
||||||
|
}>
|
||||||
|
>`SELECT
|
||||||
|
EXTRACT(MONTH FROM created_at)::int AS month,
|
||||||
|
SUM(CASE WHEN event_type = 'pr_created' THEN 1 ELSE 0 END)::bigint AS pr_created,
|
||||||
|
SUM(CASE WHEN event_type = 'pr_merged' THEN 1 ELSE 0 END)::bigint AS pr_merged,
|
||||||
|
SUM(CASE WHEN event_type = 'pr_closed' THEN 1 ELSE 0 END)::bigint AS pr_closed
|
||||||
|
FROM optimization_events
|
||||||
|
WHERE event_type IN ('pr_created', 'pr_merged', 'pr_closed')
|
||||||
|
AND created_at >= ${startDate}
|
||||||
|
AND created_at < ${endDate}
|
||||||
|
AND repository_id = ${repoId}
|
||||||
|
GROUP BY EXTRACT(MONTH FROM created_at)`
|
||||||
|
|
||||||
|
type MonthStat = { month: number; pr_created: bigint; pr_merged: bigint; pr_closed: bigint }
|
||||||
|
const statsMap = new Map<number, MonthStat>(
|
||||||
|
(monthlyStats as MonthStat[]).map((r: MonthStat) => [r.month, r]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const month = i + 1
|
||||||
|
const stats = statsMap.get(month)
|
||||||
|
return {
|
||||||
|
month: `${year}-${month.toString().padStart(2, "0")}`,
|
||||||
|
pr_created: Number(stats?.pr_created ?? 0),
|
||||||
|
pr_merged: Number(stats?.pr_merged ?? 0),
|
||||||
|
pr_closed: Number(stats?.pr_closed ?? 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch pull request event time series data:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOptimizationCountsByRepo(
|
||||||
|
repoId: string,
|
||||||
|
): Promise<{ total: number; successful: number }> {
|
||||||
|
const result = await prisma.$queryRaw<[{ total: bigint; successful: bigint }]>`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::bigint AS total,
|
||||||
|
SUM(CASE WHEN is_optimization_found THEN 1 ELSE 0 END)::bigint AS successful
|
||||||
|
FROM optimization_events
|
||||||
|
WHERE repository_id = ${repoId}`
|
||||||
|
return {
|
||||||
|
total: Number(result[0].total),
|
||||||
|
successful: Number(result[0].successful),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveUserLeaderboardLast30DaysForRepo(
|
||||||
|
repoId: string,
|
||||||
|
): Promise<{ username: string; eventCount: number; avatarUrl: string }[]> {
|
||||||
|
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const groupedCounts = await prisma.optimization_events.groupBy({
|
||||||
|
by: ["current_username"],
|
||||||
|
where: {
|
||||||
|
repository_id: repoId,
|
||||||
|
created_at: {
|
||||||
|
gte: since,
|
||||||
|
},
|
||||||
|
current_username: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
_count: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return groupedCounts.map(
|
||||||
|
(entry: { current_username: string | null; _count: { id: number } }) => ({
|
||||||
|
username: entry.current_username!,
|
||||||
|
eventCount: entry._count.id,
|
||||||
|
avatarUrl: `https://github.com/${entry.current_username}.png`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRepositoryByIdForPayload = withTiming(
|
||||||
|
"getRepositoryById",
|
||||||
|
async (payload: AccountPayload, repoId: string): Promise<RepositoryWithUsage | null> => {
|
||||||
|
try {
|
||||||
|
// Fetch repo, authorized repoIds, and recent activity count in parallel
|
||||||
|
const [repo, { repoIds }, recentEventCount] = await Promise.all([
|
||||||
|
prisma.repositories.findUnique({
|
||||||
|
where: { id: repoId },
|
||||||
|
include: { _count: { select: { repository_members: true } } },
|
||||||
|
}),
|
||||||
|
getRepositoriesForAccountCached(payload),
|
||||||
|
prisma.optimization_events.count({
|
||||||
|
where: {
|
||||||
|
repository_id: repoId,
|
||||||
|
created_at: {
|
||||||
|
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!repo || !repoIds.includes(repo.id)) return null
|
||||||
|
|
||||||
|
// Track repository view as a connection/engagement signal
|
||||||
|
const userId = "userId" in payload ? payload.userId : undefined
|
||||||
|
if (userId) {
|
||||||
|
trackRepositoryConnected(userId, {
|
||||||
|
repositoryId: repo.id,
|
||||||
|
repositoryName: repo.full_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = repo.full_name.split("/")[0]
|
||||||
|
return {
|
||||||
|
id: repo.id,
|
||||||
|
github_repo_id: repo.github_repo_id,
|
||||||
|
name: repo.name,
|
||||||
|
full_name: repo.full_name,
|
||||||
|
is_private: repo.is_private,
|
||||||
|
is_active: recentEventCount > 0,
|
||||||
|
has_github_action: repo.has_github_action,
|
||||||
|
created_at: repo.created_at,
|
||||||
|
last_optimized: repo.last_optimized,
|
||||||
|
optimizations_limit: repo.optimizations_limit,
|
||||||
|
optimizations_used: repo.optimizations_used,
|
||||||
|
organization,
|
||||||
|
avatarUrl: `https://github.com/${organization}.png`,
|
||||||
|
membersCount: repo._count.repository_members,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch repository by ID:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -21,16 +21,12 @@ import { RepositoryDetailSkeleton } from "@/components/repositories/RepositoryDe
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
getActiveUserLeaderboardLast30DaysForRepo,
|
|
||||||
getOptimizationsTimeSeriesData,
|
|
||||||
getPullRequestEventTimeSeriesData,
|
|
||||||
getRepositoryById,
|
|
||||||
getOptimizationCountsByRepo,
|
|
||||||
getRepositoryMembers,
|
getRepositoryMembers,
|
||||||
updateRepositoryMemberRole,
|
updateRepositoryMemberRole,
|
||||||
removeRepositoryMember,
|
removeRepositoryMember,
|
||||||
addRepositoryMemberById,
|
addRepositoryMemberById,
|
||||||
} from "./action"
|
} from "./action"
|
||||||
|
import { getRepoDetailInitData, getRepoDetailPrActivityData } from "./data"
|
||||||
import { GitHubUserSearchResult, Member } from "@/lib/types"
|
import { GitHubUserSearchResult, Member } from "@/lib/types"
|
||||||
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
|
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
|
||||||
import { useViewMode } from "@/app/app/ViewModeContext"
|
import { useViewMode } from "@/app/app/ViewModeContext"
|
||||||
|
|
@ -38,7 +34,6 @@ import { MembersList } from "@/components/members/members-list"
|
||||||
import { UserSearchModal } from "@/components/members/user-search-modal"
|
import { UserSearchModal } from "@/components/members/user-search-modal"
|
||||||
import { RoleSelector } from "@/components/members/role-selector"
|
import { RoleSelector } from "@/components/members/role-selector"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
import type { AccountPayload } from "@codeflash-ai/common"
|
|
||||||
|
|
||||||
// Repository Header Component
|
// Repository Header Component
|
||||||
const RepositoryHeader = ({ repository }: { repository: RepositoryWithUsage }) => {
|
const RepositoryHeader = ({ repository }: { repository: RepositoryWithUsage }) => {
|
||||||
|
|
@ -285,7 +280,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
|
||||||
}
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const result = await getRepositoryMembers(currentUserId, repoId)
|
const result = await getRepositoryMembers(repoId)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setMembers(result.data)
|
setMembers(result.data)
|
||||||
|
|
@ -295,7 +290,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}, [currentUserId, repoId, isRefreshing])
|
}, [repoId, isRefreshing])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
|
|
@ -315,7 +310,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUserAdd = async (user: GitHubUserSearchResult) => {
|
const handleUserAdd = async (user: GitHubUserSearchResult) => {
|
||||||
const result = await addRepositoryMemberById(currentUserId, repoId, user, selectedRole)
|
const result = await addRepositoryMemberById(repoId, user, selectedRole)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
handleMemberAdded()
|
handleMemberAdded()
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +322,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
const result = await updateRepositoryMemberRole(currentUserId, repoId, memberId, newRole)
|
const result = await updateRepositoryMemberRole(repoId, memberId, newRole)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess("Member role updated successfully")
|
setSuccess("Member role updated successfully")
|
||||||
|
|
@ -353,7 +348,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
const result = await removeRepositoryMember(currentUserId, repoId, memberId)
|
const result = await removeRepositoryMember(repoId, memberId)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess(`${memberUsername} has been removed successfully`)
|
setSuccess(`${memberUsername} has been removed successfully`)
|
||||||
|
|
@ -572,63 +567,23 @@ export function RepoDetailClient({
|
||||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000))
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: AccountPayload = currentOrg
|
const initData = await getRepoDetailInitData(repositoryId, selectedPrYear)
|
||||||
? { orgId: currentOrg.id }
|
|
||||||
: { userId: currentUserId, username: "" }
|
|
||||||
|
|
||||||
const currentRepo = await getRepositoryById(payload, repositoryId)
|
if (!initData?.repository || !initData.stats) {
|
||||||
|
|
||||||
if (!currentRepo) {
|
|
||||||
throw new Error("Repository not found")
|
throw new Error("Repository not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
setRepository(currentRepo)
|
setRepository(initData.repository)
|
||||||
|
setOptimizationStats({
|
||||||
// Fetch all statistics in parallel - these are all independent queries
|
totalAttempts: initData.stats.totalAttempts,
|
||||||
// Use the combined count query (single SQL) instead of two separate COUNT calls
|
successfulAttempts: initData.stats.successfulAttempts,
|
||||||
const [
|
})
|
||||||
counts,
|
setOptimizationsTrend(initData.stats.optimizationsTrend)
|
||||||
optimizationsOverTime,
|
setOptimizationsTrendDates(initData.stats.optimizationsTrendDates)
|
||||||
successfulOptimizationsOverTime,
|
setSuccessfulOptimizationsTrend(initData.stats.successfulOptimizationsTrend)
|
||||||
prData,
|
setSuccessfulOptimizationsTrendDates(initData.stats.successfulOptimizationsTrendDates)
|
||||||
leaderboardData,
|
setPrActivityData(initData.stats.prActivityData)
|
||||||
] = await Promise.all([
|
setActiveUsersData(initData.stats.activeUsersData)
|
||||||
getOptimizationCountsByRepo(repositoryId),
|
|
||||||
getOptimizationsTimeSeriesData(repositoryId, false),
|
|
||||||
getOptimizationsTimeSeriesData(repositoryId, true),
|
|
||||||
getPullRequestEventTimeSeriesData(selectedPrYear, repositoryId),
|
|
||||||
getActiveUserLeaderboardLast30DaysForRepo(repositoryId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const totalAttempts = counts.total
|
|
||||||
const successfulAttempts = counts.successful
|
|
||||||
|
|
||||||
if (Array.isArray(optimizationsOverTime) && optimizationsOverTime.length > 0) {
|
|
||||||
setOptimizationsTrend(optimizationsOverTime.map(item => item?.count || 0))
|
|
||||||
setOptimizationsTrendDates(optimizationsOverTime.map(item => item?.date || ""))
|
|
||||||
} else {
|
|
||||||
setOptimizationsTrend([])
|
|
||||||
setOptimizationsTrendDates([])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Array.isArray(successfulOptimizationsOverTime) &&
|
|
||||||
successfulOptimizationsOverTime.length > 0
|
|
||||||
) {
|
|
||||||
setSuccessfulOptimizationsTrend(
|
|
||||||
successfulOptimizationsOverTime.map(item => item?.count || 0),
|
|
||||||
)
|
|
||||||
setSuccessfulOptimizationsTrendDates(
|
|
||||||
successfulOptimizationsOverTime.map(item => item?.date || ""),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setSuccessfulOptimizationsTrend([])
|
|
||||||
setSuccessfulOptimizationsTrendDates([])
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrActivityData(Array.isArray(prData) ? prData : [])
|
|
||||||
setActiveUsersData(Array.isArray(leaderboardData) ? leaderboardData : [])
|
|
||||||
setOptimizationStats({ totalAttempts, successfulAttempts })
|
|
||||||
setRetryCount(0)
|
setRetryCount(0)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to fetch repository data (attempt ${attempt + 1}):`, err)
|
console.error(`Failed to fetch repository data (attempt ${attempt + 1}):`, err)
|
||||||
|
|
@ -654,7 +609,7 @@ export function RepoDetailClient({
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[maxRetries, selectedPrYear, repositoryId, currentOrg, currentUserId],
|
[maxRetries, selectedPrYear, repositoryId],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only refetch when org changes from what the server provided, or when prYear changes
|
// Only refetch when org changes from what the server provided, or when prYear changes
|
||||||
|
|
@ -670,8 +625,10 @@ export function RepoDetailClient({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPrYear === initialPrYearRef.current) return
|
if (selectedPrYear === initialPrYearRef.current) return
|
||||||
initialPrYearRef.current = selectedPrYear
|
initialPrYearRef.current = selectedPrYear
|
||||||
getPullRequestEventTimeSeriesData(selectedPrYear, repositoryId).then(prData => {
|
getRepoDetailPrActivityData(repositoryId, selectedPrYear).then(prData => {
|
||||||
setPrActivityData(Array.isArray(prData) ? prData : [])
|
if (prData) {
|
||||||
|
setPrActivityData(Array.isArray(prData) ? prData : [])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [selectedPrYear, repositoryId])
|
}, [selectedPrYear, repositoryId])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { auth0 } from "@/lib/auth0"
|
||||||
import { AccountPayload, buildOptimizationOrCondition, prisma } from "@codeflash-ai/common"
|
import { AccountPayload, buildOptimizationOrCondition, prisma } from "@codeflash-ai/common"
|
||||||
import * as Sentry from "@sentry/nextjs"
|
import * as Sentry from "@sentry/nextjs"
|
||||||
import { trackOptimizationReviewed } from "@/lib/analytics/tracking"
|
import { trackOptimizationReviewed } from "@/lib/analytics/tracking"
|
||||||
import { cookies } from "next/headers"
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
export interface DiffContent {
|
export interface DiffContent {
|
||||||
oldContent: string
|
oldContent: string
|
||||||
|
|
@ -32,7 +32,67 @@ export interface GetStagingCodeParams {
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStagingCodeFromApi(
|
async function findAuthorizedOptimizationEvent(
|
||||||
|
payload: AccountPayload,
|
||||||
|
identifiers: { id?: string; trace_id?: string },
|
||||||
|
queryOptions: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const repoIds = (await getRepositoriesForAccountCached(payload)).repoIds
|
||||||
|
const where = {
|
||||||
|
...identifiers,
|
||||||
|
...buildOptimizationOrCondition(payload, repoIds),
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.optimization_events.findFirst({
|
||||||
|
where,
|
||||||
|
...queryOptions,
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizedActionContext() {
|
||||||
|
return getActionAccountContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizedEventById(eventId: string, queryOptions: Record<string, unknown> = {}) {
|
||||||
|
const accountContext = await getAuthorizedActionContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await findAuthorizedOptimizationEvent(
|
||||||
|
accountContext.payload,
|
||||||
|
{ id: eventId },
|
||||||
|
queryOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountContext,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizedEventByTraceId(
|
||||||
|
traceId: string,
|
||||||
|
queryOptions: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const accountContext = await getAuthorizedActionContext()
|
||||||
|
if (!accountContext) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await findAuthorizedOptimizationEvent(
|
||||||
|
accountContext.payload,
|
||||||
|
{ trace_id: traceId },
|
||||||
|
queryOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountContext,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStagingCodeFromApi(
|
||||||
params: GetStagingCodeParams,
|
params: GetStagingCodeParams,
|
||||||
): Promise<ActionResponse<StagingCodeResponse>> {
|
): Promise<ActionResponse<StagingCodeResponse>> {
|
||||||
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
||||||
|
|
@ -95,6 +155,16 @@ export async function commitStagingCode(
|
||||||
fileChanges: Record<string, string>,
|
fileChanges: Record<string, string>,
|
||||||
commitMessage?: string,
|
commitMessage?: string,
|
||||||
): Promise<ActionResponse<CommitStagingCodeResponse>> {
|
): Promise<ActionResponse<CommitStagingCodeResponse>> {
|
||||||
|
const authorizedEvent = await getAuthorizedEventByTraceId(traceId, {
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!authorizedEvent) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
if (!authorizedEvent.event) {
|
||||||
|
return createErrorResponse("Optimization event not found")
|
||||||
|
}
|
||||||
|
|
||||||
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
||||||
const session = await auth0.getAccessToken()
|
const session = await auth0.getAccessToken()
|
||||||
|
|
||||||
|
|
@ -148,7 +218,7 @@ export async function commitStagingCode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOptimizationEventById({
|
async function getOptimizationEventById({
|
||||||
payload,
|
payload,
|
||||||
trace_id,
|
trace_id,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -206,23 +276,29 @@ export async function saveOptimizationChanges({
|
||||||
filePath,
|
filePath,
|
||||||
newContent,
|
newContent,
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
|
||||||
eventId: string
|
eventId: string
|
||||||
filePath: string
|
filePath: string
|
||||||
newContent: string
|
newContent: string
|
||||||
}) {
|
}) {
|
||||||
try {
|
const authorizedEvent = await getAuthorizedEventById(eventId, {
|
||||||
const currentEvent = await prisma.optimization_events.findUnique({
|
select: { id: true, metadata: true },
|
||||||
where: { id: eventId },
|
})
|
||||||
select: { metadata: true },
|
if (!authorizedEvent) {
|
||||||
})
|
return {
|
||||||
|
success: false,
|
||||||
if (!currentEvent) {
|
error: "Unauthorized",
|
||||||
throw new Error("Event not found")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!authorizedEvent.event) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Event not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Get the current metadata
|
// Get the current metadata
|
||||||
const currentMetadata = (currentEvent.metadata as any) || {}
|
const currentMetadata = (authorizedEvent.event.metadata as any) || {}
|
||||||
const currentDiffContents = currentMetadata.diffContents || {}
|
const currentDiffContents = currentMetadata.diffContents || {}
|
||||||
|
|
||||||
// Update only the specific file's content
|
// Update only the specific file's content
|
||||||
|
|
@ -292,6 +368,39 @@ export async function createPullRequest({
|
||||||
originalLineProfiler?: string
|
originalLineProfiler?: string
|
||||||
optimizedLineProfiler?: string
|
optimizedLineProfiler?: string
|
||||||
}): Promise<ActionResponse> {
|
}): Promise<ActionResponse> {
|
||||||
|
const authorizedEvent = await getAuthorizedEventByTraceId(traceId, {
|
||||||
|
include: {
|
||||||
|
repository: {
|
||||||
|
select: { full_name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!authorizedEvent) {
|
||||||
|
return createErrorResponse("Unauthorized")
|
||||||
|
}
|
||||||
|
if (!authorizedEvent.event) {
|
||||||
|
return createErrorResponse("Optimization event not found")
|
||||||
|
}
|
||||||
|
const authorizedRepository = (
|
||||||
|
authorizedEvent.event as { repository?: { full_name?: string | null } | null }
|
||||||
|
).repository
|
||||||
|
if (
|
||||||
|
full_repo_name &&
|
||||||
|
authorizedRepository?.full_name &&
|
||||||
|
authorizedRepository.full_name !== full_repo_name
|
||||||
|
if (full_repo_name && !authorizedRepository?.full_name) {
|
||||||
|
return createErrorResponse("Repository not found for this optimization event")
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
full_repo_name &&
|
||||||
|
authorizedRepository?.full_name &&
|
||||||
|
authorizedRepository.full_name !== full_repo_name
|
||||||
|
) {
|
||||||
|
return createErrorResponse("Repository mismatch for optimization event")
|
||||||
|
}
|
||||||
|
return createErrorResponse("Repository mismatch for optimization event")
|
||||||
|
}
|
||||||
|
|
||||||
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
|
||||||
const session = await auth0.getAccessToken()
|
const session = await auth0.getAccessToken()
|
||||||
|
|
||||||
|
|
@ -395,6 +504,16 @@ export async function createPullRequest({
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setApprovalStatus(eventId: string, status: "approved" | "rejected") {
|
export async function setApprovalStatus(eventId: string, status: "approved" | "rejected") {
|
||||||
|
const authorizedEvent = await getAuthorizedEventById(eventId, {
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!authorizedEvent) {
|
||||||
|
return { success: false, error: "Unauthorized" }
|
||||||
|
}
|
||||||
|
if (!authorizedEvent.event) {
|
||||||
|
return { success: false, error: "Event not found" }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await prisma.optimization_events.update({
|
const updated = await prisma.optimization_events.update({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -411,20 +530,22 @@ export async function setApprovalStatus(eventId: string, status: "approved" | "r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addComment({
|
export async function addComment({ eventId, content }: { eventId: string; content: string }) {
|
||||||
eventId,
|
const authorizedEvent = await getAuthorizedEventById(eventId, {
|
||||||
userId,
|
select: { id: true },
|
||||||
content,
|
})
|
||||||
}: {
|
if (!authorizedEvent) {
|
||||||
eventId: string
|
return { success: false, error: "Unauthorized" }
|
||||||
userId: string
|
}
|
||||||
content: string
|
if (!authorizedEvent.event) {
|
||||||
}) {
|
return { success: false, error: "Event not found" }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const comment = await prisma.comments.create({
|
const comment = await prisma.comments.create({
|
||||||
data: {
|
data: {
|
||||||
optimization_event_id: eventId,
|
optimization_event_id: eventId,
|
||||||
author_user_id: userId,
|
author_user_id: authorizedEvent.accountContext.userId,
|
||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -439,6 +560,22 @@ export async function addComment({
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCommentsByEvent(eventId: string) {
|
export async function getCommentsByEvent(eventId: string) {
|
||||||
|
const authorizedEvent = await getAuthorizedEventById(eventId, {
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!authorizedEvent) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!authorizedEvent.event) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Event not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const comments = await prisma.comments.findMany({
|
const comments = await prisma.comments.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -474,24 +611,18 @@ export async function getCommentsByEvent(eventId: string) {
|
||||||
* Called from the server component to eliminate the client-side data-fetching waterfall.
|
* Called from the server component to eliminate the client-side data-fetching waterfall.
|
||||||
*/
|
*/
|
||||||
export async function getReviewPageInitData(traceId: string) {
|
export async function getReviewPageInitData(traceId: string) {
|
||||||
const session = await auth0.getSession()
|
const accountContext = await getActionAccountContext()
|
||||||
if (!session?.user?.sub || !session?.user?.nickname) {
|
if (!accountContext) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.sub
|
|
||||||
const username = session.user.nickname
|
|
||||||
|
|
||||||
// Read org cookie to determine payload
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const orgId = cookieStore.get("currentOrganizationId")?.value
|
|
||||||
|
|
||||||
const payload: AccountPayload = orgId ? { orgId } : { userId, username }
|
|
||||||
|
|
||||||
// Fetch the optimization event
|
// Fetch the optimization event
|
||||||
const event = await getOptimizationEventById({ payload, trace_id: traceId })
|
const event = await getOptimizationEventById({
|
||||||
|
payload: accountContext.payload,
|
||||||
|
trace_id: traceId,
|
||||||
|
})
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return { userId, username, event: null, comments: [], stagingCode: null }
|
return { event: null, comments: [], stagingCode: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// If git_branch storage, fetch staging code + comments in parallel
|
// If git_branch storage, fetch staging code + comments in parallel
|
||||||
|
|
@ -514,8 +645,6 @@ export async function getReviewPageInitData(traceId: string) {
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
event,
|
event,
|
||||||
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
|
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
|
||||||
stagingCode: stagingCodeResult.success ? (stagingCodeResult.data ?? null) : null,
|
stagingCode: stagingCodeResult.success ? (stagingCodeResult.data ?? null) : null,
|
||||||
|
|
@ -527,8 +656,6 @@ export async function getReviewPageInitData(traceId: string) {
|
||||||
const commentsResult = await getCommentsByEvent(event.id)
|
const commentsResult = await getCommentsByEvent(event.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
event,
|
event,
|
||||||
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
|
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
|
||||||
stagingCode: null,
|
stagingCode: null,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ export default async function OptimizationReviewPage({ params }: ReviewPageProps
|
||||||
return (
|
return (
|
||||||
<OptimizationReviewClient
|
<OptimizationReviewClient
|
||||||
traceId={traceId}
|
traceId={traceId}
|
||||||
initialUserId={initData.userId}
|
|
||||||
initialUsername={initData.username}
|
|
||||||
initialEvent={initData.event as any}
|
initialEvent={initData.event as any}
|
||||||
initialComments={initData.comments as any}
|
initialComments={initData.comments as any}
|
||||||
initialStagingCode={initData.stagingCode as any}
|
initialStagingCode={initData.stagingCode as any}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { notFound } from "next/navigation"
|
import { notFound, redirect } from "next/navigation"
|
||||||
import { getReviewPageInitData } from "../action"
|
import { getReviewPageInitData } from "../action"
|
||||||
import { ProfilerClient } from "./profiler-client"
|
import { ProfilerClient } from "./profiler-client"
|
||||||
|
|
||||||
|
|
@ -11,7 +11,11 @@ export default async function LineProfilerPage({ params }: ProfilerPageProps) {
|
||||||
|
|
||||||
const initData = await getReviewPageInitData(traceId)
|
const initData = await getReviewPageInitData(traceId)
|
||||||
|
|
||||||
if (!initData || !initData.event) {
|
if (!initData) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initData.event) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,12 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
getOptimizationEventById,
|
|
||||||
saveOptimizationChanges,
|
saveOptimizationChanges,
|
||||||
setApprovalStatus,
|
setApprovalStatus,
|
||||||
addComment,
|
addComment,
|
||||||
getCommentsByEvent,
|
getCommentsByEvent,
|
||||||
getStagingCodeFromApi,
|
|
||||||
commitStagingCode,
|
commitStagingCode,
|
||||||
|
getReviewPageInitData,
|
||||||
type StagingCodeResponse,
|
type StagingCodeResponse,
|
||||||
} from "./action"
|
} from "./action"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
|
@ -151,8 +150,6 @@ interface SaveOptimizationResult {
|
||||||
|
|
||||||
export interface ReviewClientProps {
|
export interface ReviewClientProps {
|
||||||
traceId: string
|
traceId: string
|
||||||
initialUserId: string
|
|
||||||
initialUsername: string
|
|
||||||
initialEvent: RawOptimizationEvent | null
|
initialEvent: RawOptimizationEvent | null
|
||||||
initialComments: Comment[]
|
initialComments: Comment[]
|
||||||
initialStagingCode: StagingCodeResponse | null
|
initialStagingCode: StagingCodeResponse | null
|
||||||
|
|
@ -196,8 +193,6 @@ function transformEvent(
|
||||||
|
|
||||||
export function OptimizationReviewClient({
|
export function OptimizationReviewClient({
|
||||||
traceId,
|
traceId,
|
||||||
initialUserId,
|
|
||||||
initialUsername,
|
|
||||||
initialEvent,
|
initialEvent,
|
||||||
initialComments,
|
initialComments,
|
||||||
initialStagingCode,
|
initialStagingCode,
|
||||||
|
|
@ -208,7 +203,6 @@ export function OptimizationReviewClient({
|
||||||
)
|
)
|
||||||
const [loading, setLoading] = useState(!initialEvent)
|
const [loading, setLoading] = useState(!initialEvent)
|
||||||
const [creatingPR, setCreatingPR] = useState(false)
|
const [creatingPR, setCreatingPR] = useState(false)
|
||||||
const [userId] = useState<string>(initialUserId)
|
|
||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||||
const [isCommitting, setIsCommitting] = useState(false)
|
const [isCommitting, setIsCommitting] = useState(false)
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||||
|
|
@ -227,7 +221,7 @@ export function OptimizationReviewClient({
|
||||||
// State for base branch dialog
|
// State for base branch dialog
|
||||||
const [showBaseBranchDialog, setShowBaseBranchDialog] = useState(false)
|
const [showBaseBranchDialog, setShowBaseBranchDialog] = useState(false)
|
||||||
|
|
||||||
const currentOrgId = currentOrg?.id
|
const currentOrgId = currentOrg?.id ?? null
|
||||||
// Track the org ID used for the server-prefetched data
|
// Track the org ID used for the server-prefetched data
|
||||||
const initialOrgIdRef = useRef(currentOrgId)
|
const initialOrgIdRef = useRef(currentOrgId)
|
||||||
|
|
||||||
|
|
@ -237,6 +231,7 @@ export function OptimizationReviewClient({
|
||||||
if (currentOrgId === initialOrgIdRef.current) {
|
if (currentOrgId === initialOrgIdRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
initialOrgIdRef.current = currentOrgId
|
||||||
// Prevent concurrent calls
|
// Prevent concurrent calls
|
||||||
if (isLoadingRef.current) {
|
if (isLoadingRef.current) {
|
||||||
return
|
return
|
||||||
|
|
@ -246,62 +241,18 @@ export function OptimizationReviewClient({
|
||||||
isLoadingRef.current = true
|
isLoadingRef.current = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await getOptimizationEventById({
|
const data = await getReviewPageInitData(traceId)
|
||||||
payload: currentOrgId
|
if (data?.event) {
|
||||||
? { orgId: currentOrgId }
|
setComments((data.comments as Comment[]) ?? [])
|
||||||
: { userId: initialUserId, username: initialUsername },
|
setEvent(
|
||||||
trace_id: traceId,
|
transformEvent(
|
||||||
})
|
data.event as unknown as RawOptimizationEvent,
|
||||||
if (data) {
|
(data.stagingCode as StagingCodeResponse | null | undefined) ?? null,
|
||||||
const rawData = data as unknown as RawOptimizationEvent
|
),
|
||||||
let metadata = rawData.metadata as EventMetadata
|
)
|
||||||
|
|
||||||
if (rawData.staging_storage_type === "git_branch") {
|
|
||||||
const eventMetadata = rawData.metadata as EventMetadata
|
|
||||||
const stagingBranchName = eventMetadata?.staging_branch_name
|
|
||||||
const repository = rawData.repository
|
|
||||||
|
|
||||||
if (stagingBranchName && repository?.full_name && repository?.installation_id) {
|
|
||||||
const [stagingCodeResult] = await Promise.all([
|
|
||||||
getStagingCodeFromApi({
|
|
||||||
stagingBranchName,
|
|
||||||
baseBranch: rawData.baseBranch || "main",
|
|
||||||
fullRepoName: repository.full_name,
|
|
||||||
installationId: repository.installation_id,
|
|
||||||
functionName: rawData.function_name || undefined,
|
|
||||||
filePath: rawData.file_path || undefined,
|
|
||||||
}),
|
|
||||||
loadComments(data.id),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (stagingCodeResult.success && stagingCodeResult.data) {
|
|
||||||
const diffContentsResult = stagingCodeResult.data.diffContents
|
|
||||||
const isDiffEmpty =
|
|
||||||
!diffContentsResult || Object.keys(diffContentsResult).length === 0
|
|
||||||
if (!isDiffEmpty) {
|
|
||||||
metadata = {
|
|
||||||
...metadata,
|
|
||||||
diffContents: diffContentsResult,
|
|
||||||
staging_storage_type: "git_branch",
|
|
||||||
staging_branch_name: stagingCodeResult.data.stagingBranchName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
stagingCodeResult.error || "Failed to fetch staging code from repository",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvent(
|
|
||||||
transformEvent({ ...rawData, metadata } as unknown as RawOptimizationEvent, null),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setEvent(transformEvent(rawData, null))
|
|
||||||
await loadComments(data.id)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setEvent(null)
|
setEvent(null)
|
||||||
|
setComments([])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load optimization event:", error)
|
console.error("Failed to load optimization event:", error)
|
||||||
|
|
@ -312,7 +263,7 @@ export function OptimizationReviewClient({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refetchEvent()
|
refetchEvent()
|
||||||
}, [currentOrgId, traceId, initialUserId, initialUsername])
|
}, [currentOrgId, traceId])
|
||||||
|
|
||||||
const loadComments = async (eventId: string) => {
|
const loadComments = async (eventId: string) => {
|
||||||
setLoadingComments(true)
|
setLoadingComments(true)
|
||||||
|
|
@ -396,7 +347,7 @@ export function OptimizationReviewClient({
|
||||||
// Handle autosave edits with database persistence (only for plain_text storage)
|
// Handle autosave edits with database persistence (only for plain_text storage)
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
async (filePath: string, newContent: string) => {
|
async (filePath: string, newContent: string) => {
|
||||||
if (!event || !userId) return
|
if (!event) return
|
||||||
|
|
||||||
// Skip autosave for git_branch storage - use manual commit instead
|
// Skip autosave for git_branch storage - use manual commit instead
|
||||||
if (event.staging_storage_type === "git_branch") {
|
if (event.staging_storage_type === "git_branch") {
|
||||||
|
|
@ -412,7 +363,6 @@ export function OptimizationReviewClient({
|
||||||
const timeoutId = setTimeout(async () => {
|
const timeoutId = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = (await saveOptimizationChanges({
|
const result = (await saveOptimizationChanges({
|
||||||
userId,
|
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
filePath,
|
filePath,
|
||||||
newContent,
|
newContent,
|
||||||
|
|
@ -451,11 +401,11 @@ export function OptimizationReviewClient({
|
||||||
console.error("Error in handleEdit:", error)
|
console.error("Error in handleEdit:", error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[event, userId],
|
[event],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSubmitReview = async (status: "approved" | "rejected") => {
|
const handleSubmitReview = async (status: "approved" | "rejected") => {
|
||||||
if (!event || !userId) return
|
if (!event) return
|
||||||
|
|
||||||
setIsUpdatingStatus(true)
|
setIsUpdatingStatus(true)
|
||||||
|
|
||||||
|
|
@ -475,13 +425,12 @@ export function OptimizationReviewClient({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddComment = async () => {
|
const handleAddComment = async () => {
|
||||||
if (!event || !userId || !newComment.trim()) return
|
if (!event || !newComment.trim()) return
|
||||||
|
|
||||||
setIsSubmittingComment(true)
|
setIsSubmittingComment(true)
|
||||||
try {
|
try {
|
||||||
const commentResult = await addComment({
|
const commentResult = await addComment({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
userId,
|
|
||||||
content: newComment.trim(),
|
content: newComment.trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ vi.mock("@/lib/services/repository-utils", () => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Use realistic test fixtures: valid UUIDs and Auth0-style user IDs
|
// 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 mockRepoIds = ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f678-9012-bcde-f12345678901"]
|
||||||
|
|
||||||
const mockEvents = [
|
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", () => {
|
describe("getAllOptimizationEvents", () => {
|
||||||
let getAllOptimizationEvents: typeof import("../action").getAllOptimizationEvents
|
let getAllOptimizationEvents: typeof import("../action").getAllOptimizationEvents
|
||||||
|
|
||||||
|
|
@ -56,29 +64,32 @@ describe("getAllOptimizationEvents", () => {
|
||||||
repos: [],
|
repos: [],
|
||||||
} as any)
|
} 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")
|
const mod = await import("../action")
|
||||||
getAllOptimizationEvents = mod.getAllOptimizationEvents
|
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 () => {
|
it("calls findMany and count in parallel", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any)
|
||||||
.mockResolvedValueOnce(mockEvents)
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(2)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(2) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
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 () => {
|
it("batch-fetches optimization_features by trace_id array (not N+1)", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any)
|
||||||
.mockResolvedValueOnce(mockEvents)
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(2)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(2) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue(mockFeatures as any)
|
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
|
// Single batch query with all trace IDs — NOT one per event
|
||||||
expect(prisma.optimization_features.findMany).toHaveBeenCalledTimes(1)
|
expect(prisma.optimization_features.findMany).toHaveBeenCalledTimes(1)
|
||||||
|
|
@ -93,12 +104,11 @@ describe("getAllOptimizationEvents", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("merges review_quality into events", async () => {
|
it("merges review_quality into events", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue(mockEvents as any)
|
||||||
.mockResolvedValueOnce(mockEvents)
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(2)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(2) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue(mockFeatures as any)
|
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_quality).toBe("high")
|
||||||
expect((result.events[0] as any).review_explanation).toBe("Great optimization")
|
expect((result.events[0] as any).review_explanation).toBe("Great optimization")
|
||||||
|
|
@ -106,120 +116,120 @@ describe("getAllOptimizationEvents", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns totalCount from count query", async () => {
|
it("returns totalCount from count query", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([])
|
||||||
.mockResolvedValueOnce([])
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(42)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(42) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
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)
|
expect(result.totalCount).toBe(42)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("applies pagination with skip and take", async () => {
|
it("applies pagination with skip and take", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([])
|
||||||
.mockResolvedValueOnce([])
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(0)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
page: 3,
|
page: 3,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check that OFFSET is calculated correctly in the SQL
|
expect(prisma.optimization_events.findMany).toHaveBeenCalledWith(
|
||||||
const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string
|
expect.objectContaining({
|
||||||
expect(sql).toContain("OFFSET 50") // (3 - 1) * 25
|
skip: 50, // (3 - 1) * 25
|
||||||
expect(sql).toContain("LIMIT 25")
|
take: 25,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("uses default sort (created_at desc) when no sort provided", async () => {
|
it("uses default sort (created_at desc) when no sort provided", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([])
|
||||||
.mockResolvedValueOnce([])
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(0)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
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(prisma.optimization_events.findMany).toHaveBeenCalledWith(
|
||||||
expect(sql).toContain("ORDER BY oe.created_at DESC")
|
expect.objectContaining({
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("applies search filter", async () => {
|
it("applies search filter", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([])
|
||||||
.mockResolvedValueOnce([])
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(0)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
search: "calc",
|
search: "calc",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check that search is included in the SQL
|
// Check findMany was called with a search-containing where clause
|
||||||
const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string
|
const call = vi.mocked(prisma.optimization_events.findMany).mock.calls[0][0] as any
|
||||||
expect(sql).toContain("oe.function_name ILIKE $1")
|
// Should have AND with OR containing the search fields
|
||||||
expect(sql).toContain("oe.file_path ILIKE $1")
|
expect(call.where.AND).toBeDefined()
|
||||||
expect(sql).toContain("r.full_name ILIKE $1")
|
const orClause = call.where.AND.find((c: any) => c.OR)
|
||||||
// Check params include the search term
|
expect(orClause).toBeDefined()
|
||||||
const params = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0].slice(1)
|
expect(orClause.OR).toHaveLength(3) // function_name, file_path, repository.full_name
|
||||||
expect(params[0]).toBe("%calc%")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("applies repository_id filter", async () => {
|
it("applies repository_id filter", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
vi.mocked(prisma.optimization_events.findMany).mockResolvedValue([])
|
||||||
.mockResolvedValueOnce([])
|
vi.mocked(prisma.optimization_events.count).mockResolvedValue(0)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
|
||||||
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
vi.mocked(prisma.optimization_features.findMany).mockResolvedValue([])
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
filter: { repository_id: mockRepoIds[0] },
|
filter: { repository_id: mockRepoIds[0] },
|
||||||
})
|
})
|
||||||
|
|
||||||
// In the new UNION-based implementation, additional filters are NOT supported
|
const call = vi.mocked(prisma.optimization_events.findMany).mock.calls[0][0] as any
|
||||||
// because they would require complex WHERE clause merging across UNION branches.
|
// The repository_id filter should be in the AND clause
|
||||||
// This test now verifies the query runs without errors (which is a valid regression test).
|
const repoFilter = call.where.AND.find((c: any) => c.repository_id !== undefined)
|
||||||
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2)
|
expect(repoFilter).toBeDefined()
|
||||||
|
expect(repoFilter.repository_id).toBe(mockRepoIds[0])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Path A: raw SQL query (review_quality sort/filter)", () => {
|
describe("Path A: raw SQL query (review_quality sort/filter)", () => {
|
||||||
it("triggers when sort includes review_quality", async () => {
|
it("triggers when sort includes review_quality", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce([]) // events
|
.mockResolvedValueOnce([]) // events
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }]) // count
|
.mockResolvedValueOnce([{ count: BigInt(0) }]) // count
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
sort: { review_quality: "desc" },
|
sort: { review_quality: "desc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2)
|
expect((prisma as any).$queryRaw).toHaveBeenCalledTimes(2)
|
||||||
// Should NOT use standard Prisma findMany
|
// Should NOT use standard Prisma findMany
|
||||||
expect(prisma.optimization_events.findMany).not.toHaveBeenCalled()
|
expect(prisma.optimization_events.findMany).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("triggers when filter includes review_quality", async () => {
|
it("triggers when filter includes review_quality", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
filter: { review_quality: "high" },
|
filter: { review_quality: "high" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(2)
|
expect((prisma as any).$queryRaw).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns correct totalCount from BigInt conversion", async () => {
|
it("returns correct totalCount from BigInt conversion", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
.mockResolvedValueOnce([{ count: BigInt(99) }])
|
.mockResolvedValueOnce([{ count: BigInt(99) }])
|
||||||
|
|
||||||
const result = await getAllOptimizationEvents({
|
const result = await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
sort: { review_quality: "asc" },
|
sort: { review_quality: "asc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -238,12 +248,12 @@ describe("getAllOptimizationEvents", () => {
|
||||||
repo_id: mockRepoIds[0],
|
repo_id: mockRepoIds[0],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce(rawEvents)
|
.mockResolvedValueOnce(rawEvents)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
||||||
|
|
||||||
const result = await getAllOptimizationEvents({
|
const result = await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
sort: { review_quality: "desc" },
|
sort: { review_quality: "desc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -266,12 +276,12 @@ describe("getAllOptimizationEvents", () => {
|
||||||
repo_id: null,
|
repo_id: null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce(rawEvents)
|
.mockResolvedValueOnce(rawEvents)
|
||||||
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
||||||
|
|
||||||
const result = await getAllOptimizationEvents({
|
const result = await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
sort: { review_quality: "desc" },
|
sort: { review_quality: "desc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -279,16 +289,17 @@ describe("getAllOptimizationEvents", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("includes LEFT JOIN in raw SQL queries", async () => {
|
it("includes LEFT JOIN in raw SQL queries", async () => {
|
||||||
vi.mocked(prisma.$queryRawUnsafe)
|
;(prisma as any).$queryRaw
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
||||||
|
|
||||||
await getAllOptimizationEvents({
|
await getAllOptimizationEvents({
|
||||||
payload: mockPayload as any,
|
payload: mockOrgPayload as any,
|
||||||
sort: { review_quality: "desc" },
|
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 optimization_features")
|
||||||
expect(sql).toContain("LEFT JOIN repositories")
|
expect(sql).toContain("LEFT JOIN repositories")
|
||||||
})
|
})
|
||||||
|
|
@ -301,7 +312,7 @@ describe("getAllOptimizationEvents", () => {
|
||||||
repos: [],
|
repos: [],
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
const result = await getAllOptimizationEvents({ payload: mockPayload as any })
|
const result = await getAllOptimizationEvents({ payload: mockPersonalPayload as any })
|
||||||
expect(result.events).toEqual([])
|
expect(result.events).toEqual([])
|
||||||
expect(result.totalCount).toBe(0)
|
expect(result.totalCount).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -79,86 +79,87 @@ export const getRepositoriesWithStagingEvents = withTiming(
|
||||||
getRepositoriesWithStagingEventsImpl,
|
getRepositoriesWithStagingEventsImpl,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cached implementation for getAllOptimizationEvents
|
// Note: React cache() is NOT used here because this function takes an object argument
|
||||||
// React cache() deduplicates calls with identical arguments within a single request
|
// (reference equality means cache never hits). Deduplication happens at a higher level.
|
||||||
const getAllOptimizationEventsImpl = cache(
|
const getAllOptimizationEventsImpl = async ({
|
||||||
async ({
|
payload,
|
||||||
payload,
|
search,
|
||||||
search,
|
filter,
|
||||||
filter,
|
sort,
|
||||||
sort,
|
page = 1,
|
||||||
page = 1,
|
pageSize = 10,
|
||||||
pageSize = 10,
|
}: {
|
||||||
}: {
|
payload: AccountPayload
|
||||||
payload: AccountPayload
|
search?: string
|
||||||
search?: string
|
filter?: Record<string, any>
|
||||||
filter?: Record<string, any>
|
sort?: { [key: string]: "asc" | "desc" }
|
||||||
sort?: { [key: string]: "asc" | "desc" }
|
page?: number
|
||||||
page?: number
|
pageSize?: number
|
||||||
pageSize?: number
|
}) => {
|
||||||
}) => {
|
const repoIds = (await getRepositoriesForAccountCached(payload)).repoIds
|
||||||
const repoIds = (await getRepositoriesForAccountCached(payload)).repoIds
|
|
||||||
|
|
||||||
if (repoIds.length === 0) {
|
if (repoIds.length === 0) {
|
||||||
return { events: [], totalCount: 0 }
|
return { events: [], totalCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsOptimizationFeaturesJoin =
|
const needsOptimizationFeaturesJoin =
|
||||||
(sort && Object.keys(sort).some(k => k.toLowerCase() === "review_quality")) ||
|
(sort && Object.keys(sort).some(k => k.toLowerCase() === "review_quality")) ||
|
||||||
(filter && Object.keys(filter).some(k => k.toLowerCase() === "review_quality"))
|
(filter && Object.keys(filter).some(k => k.toLowerCase() === "review_quality"))
|
||||||
|
|
||||||
if (needsOptimizationFeaturesJoin) {
|
if (needsOptimizationFeaturesJoin) {
|
||||||
// Raw SQL path for review_quality sorting/filtering
|
// Raw SQL path for review_quality sorting/filtering
|
||||||
const whereFragments: Prisma.Sql[] = [Prisma.sql`oe.is_staging = true`]
|
const whereFragments: Prisma.Sql[] = [Prisma.sql`oe.is_staging = true`]
|
||||||
|
|
||||||
if ("orgId" in payload) {
|
if ("orgId" in payload) {
|
||||||
whereFragments.push(Prisma.sql`oe.repository_id IN (${Prisma.join(repoIds)})`)
|
whereFragments.push(Prisma.sql`oe.repository_id IN (${Prisma.join(repoIds)})`)
|
||||||
} else {
|
} else {
|
||||||
// For personal accounts, use OR pattern in WHERE (raw SQL already, so bitmap merge is acceptable here
|
// 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,
|
// 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).
|
// which is now fixed above. This path is rarely hit (only when sorting by review_quality).
|
||||||
whereFragments.push(
|
whereFragments.push(
|
||||||
Prisma.sql`(
|
Prisma.sql`(
|
||||||
oe.repository_id IN (${Prisma.join(repoIds)})
|
oe.repository_id IN (${Prisma.join(repoIds)})
|
||||||
OR oe.user_id = ${payload.userId}
|
OR oe.user_id = ${payload.userId}
|
||||||
OR oe.current_username = ${payload.username}
|
OR oe.current_username = ${payload.username}
|
||||||
)`,
|
)`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add search conditions
|
// Add search conditions
|
||||||
if (search) {
|
if (search) {
|
||||||
const searchPattern = `%${search}%`
|
const searchPattern = `%${search}%`
|
||||||
whereFragments.push(
|
whereFragments.push(
|
||||||
Prisma.sql`(oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})`,
|
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.event_type) {
|
||||||
if (filter) {
|
whereFragments.push(Prisma.sql`oe.event_type = ${filter.event_type}`)
|
||||||
if (filter.status) {
|
}
|
||||||
whereFragments.push(Prisma.sql`oe.status = ${filter.status}`)
|
if (filter.review_quality) {
|
||||||
}
|
whereFragments.push(Prisma.sql`of.review_quality = ${filter.review_quality}`)
|
||||||
if (filter.event_type) {
|
}
|
||||||
whereFragments.push(Prisma.sql`oe.event_type = ${filter.event_type}`)
|
if (filter.repository_id !== undefined) {
|
||||||
}
|
if (filter.repository_id === null) {
|
||||||
if (filter.review_quality) {
|
whereFragments.push(Prisma.sql`oe.repository_id IS NULL`)
|
||||||
whereFragments.push(Prisma.sql`of.review_quality = ${filter.review_quality}`)
|
} else if (filter.repository_id.not !== undefined && filter.repository_id.not === null) {
|
||||||
}
|
whereFragments.push(Prisma.sql`oe.repository_id IS NOT NULL`)
|
||||||
if (filter.repository_id !== undefined) {
|
} else if (typeof filter.repository_id === "string") {
|
||||||
if (filter.repository_id === null) {
|
whereFragments.push(Prisma.sql`oe.repository_id = ${filter.repository_id}`)
|
||||||
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`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const whereClause = Prisma.join(whereFragments, " AND ")
|
}
|
||||||
const orderByClauses: Prisma.Sql[] = []
|
const whereClause = Prisma.join(whereFragments, " AND ")
|
||||||
if (sort && Object.keys(sort).length > 0) {
|
const orderByClauses: Prisma.Sql[] = []
|
||||||
Object.entries(sort).forEach(([key, direction]) => {
|
if (sort && Object.keys(sort).length > 0) {
|
||||||
const dir = direction.toUpperCase() === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`
|
Object.entries(sort).forEach(([key, direction]) => {
|
||||||
if (key.toLowerCase() === "review_quality") {
|
const dir = direction.toUpperCase() === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`
|
||||||
orderByClauses.push(Prisma.sql`
|
if (key.toLowerCase() === "review_quality") {
|
||||||
|
orderByClauses.push(Prisma.sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN LOWER(of.review_quality) = 'high' THEN 3
|
WHEN LOWER(of.review_quality) = 'high' THEN 3
|
||||||
WHEN LOWER(of.review_quality) = 'medium' THEN 2
|
WHEN LOWER(of.review_quality) = 'medium' THEN 2
|
||||||
|
|
@ -166,20 +167,20 @@ const getAllOptimizationEventsImpl = cache(
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END ${dir}
|
END ${dir}
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
const col = key === "created_at" ? Prisma.sql`oe.created_at` : Prisma.raw(`oe.${key}`)
|
const col = key === "created_at" ? Prisma.sql`oe.created_at` : Prisma.raw(`oe.${key}`)
|
||||||
orderByClauses.push(Prisma.sql`${col} ${dir}`)
|
orderByClauses.push(Prisma.sql`${col} ${dir}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!sort) {
|
if (!sort) {
|
||||||
orderByClauses.push(Prisma.sql`oe.created_at DESC`)
|
orderByClauses.push(Prisma.sql`oe.created_at DESC`)
|
||||||
}
|
}
|
||||||
const orderByClause = Prisma.join(orderByClauses, ", ")
|
const orderByClause = Prisma.join(orderByClauses, ", ")
|
||||||
const paginationLimit = pageSize
|
const paginationLimit = pageSize
|
||||||
const paginationOffset = (page - 1) * pageSize
|
const paginationOffset = (page - 1) * pageSize
|
||||||
const [events, countResult] = await Promise.all([
|
const [events, countResult] = await Promise.all([
|
||||||
prisma.$queryRaw<any[]>`
|
prisma.$queryRaw<any[]>`
|
||||||
SELECT
|
SELECT
|
||||||
oe.*,
|
oe.*,
|
||||||
of.review_quality,
|
of.review_quality,
|
||||||
|
|
@ -194,134 +195,136 @@ const getAllOptimizationEventsImpl = cache(
|
||||||
ORDER BY ${orderByClause}
|
ORDER BY ${orderByClause}
|
||||||
LIMIT ${paginationLimit} OFFSET ${paginationOffset}
|
LIMIT ${paginationLimit} OFFSET ${paginationOffset}
|
||||||
`,
|
`,
|
||||||
prisma.$queryRaw<[{ count: bigint }]>`
|
prisma.$queryRaw<[{ count: bigint }]>`
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM optimization_events oe
|
FROM optimization_events oe
|
||||||
LEFT JOIN optimization_features of ON oe.trace_id = of.trace_id
|
LEFT JOIN optimization_features of ON oe.trace_id = of.trace_id
|
||||||
LEFT JOIN repositories r ON oe.repository_id = r.id
|
LEFT JOIN repositories r ON oe.repository_id = r.id
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
`,
|
`,
|
||||||
])
|
])
|
||||||
const totalCount = Number(countResult[0].count)
|
const totalCount = Number(countResult[0].count)
|
||||||
// Repository data is already included from the JOIN
|
// Repository data is already included from the JOIN
|
||||||
const eventsWithRepo = events.map(
|
const eventsWithRepo = events.map(
|
||||||
(
|
(
|
||||||
event: Record<string, unknown> & {
|
event: Record<string, unknown> & {
|
||||||
repo_id?: string
|
repo_id?: string
|
||||||
repo_full_name?: string
|
repo_full_name?: string
|
||||||
repo_name?: string
|
repo_name?: string
|
||||||
},
|
},
|
||||||
) => ({
|
) => ({
|
||||||
...event,
|
...event,
|
||||||
repository: event.repo_id
|
repository: event.repo_id
|
||||||
? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name }
|
? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name }
|
||||||
: null,
|
: null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return { events: eventsWithRepo, totalCount }
|
return { events: eventsWithRepo, totalCount }
|
||||||
} else {
|
} else {
|
||||||
// Standard Prisma query with native orderBy (optimized with UNION for personal accounts)
|
// Standard Prisma query with native orderBy (optimized with UNION for personal accounts)
|
||||||
const orderBy = sort || { created_at: "desc" as const }
|
const orderBy = sort || { created_at: "desc" as const }
|
||||||
|
|
||||||
let events
|
let events
|
||||||
let totalCount
|
let totalCount
|
||||||
|
|
||||||
if ("orgId" in payload) {
|
if ("orgId" in payload) {
|
||||||
// Organization account: simple IN clause
|
// Organization account: simple IN clause
|
||||||
const where = {
|
const where = {
|
||||||
is_staging: true,
|
is_staging: true,
|
||||||
repository_id: { in: repoIds },
|
repository_id: { in: repoIds },
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.AND = where.AND || []
|
where.AND = where.AND || []
|
||||||
where.AND.push({
|
where.AND.push({
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
function_name: {
|
function_name: {
|
||||||
contains: search,
|
contains: search,
|
||||||
mode: "insensitive" as const,
|
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 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
{
|
||||||
prisma.optimization_events.count({ where }),
|
file_path: {
|
||||||
])
|
contains: search,
|
||||||
} else {
|
mode: "insensitive" as const,
|
||||||
// Personal account: Use raw SQL with UNION for efficient index seeks
|
},
|
||||||
let searchCondition = Prisma.empty
|
},
|
||||||
if (search) {
|
{
|
||||||
const searchPattern = `%${search}%`
|
repository: {
|
||||||
searchCondition = Prisma.sql`AND (oe.function_name ILIKE ${searchPattern} OR oe.file_path ILIKE ${searchPattern} OR r.full_name ILIKE ${searchPattern})`
|
full_name: {
|
||||||
}
|
contains: search,
|
||||||
|
mode: "insensitive" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const filterFragments: Prisma.Sql[] = []
|
if (filter) {
|
||||||
if (filter) {
|
Object.keys(filter).forEach(key => {
|
||||||
Object.entries(filter).forEach(([key, value]) => {
|
if (key === "repository_id") {
|
||||||
if (key === "status") {
|
where.AND = where.AND || []
|
||||||
filterFragments.push(Prisma.sql`AND oe.status = ${value}`)
|
where.AND.push({ [key]: filter[key] })
|
||||||
} else if (key === "event_type") {
|
} else if (key !== "review_quality") {
|
||||||
filterFragments.push(Prisma.sql`AND oe.event_type = ${value}`)
|
where[key] = filter[key]
|
||||||
} 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`)
|
;[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 =
|
const orderByDir =
|
||||||
typeof orderBy === "object" && orderBy.created_at === "asc"
|
typeof orderBy === "object" && orderBy.created_at === "asc"
|
||||||
? Prisma.sql`ASC`
|
? Prisma.sql`ASC`
|
||||||
: Prisma.sql`DESC`
|
: Prisma.sql`DESC`
|
||||||
|
|
||||||
const paginationLimit = pageSize
|
const paginationLimit = pageSize
|
||||||
const paginationOffset = (page - 1) * pageSize
|
const paginationOffset = (page - 1) * pageSize
|
||||||
|
|
||||||
const unionSubquery = Prisma.sql`
|
const unionSubquery = Prisma.sql`
|
||||||
SELECT id FROM (
|
SELECT id FROM (
|
||||||
SELECT id FROM optimization_events
|
SELECT id FROM optimization_events
|
||||||
WHERE is_staging = true
|
WHERE is_staging = true
|
||||||
|
|
@ -335,8 +338,8 @@ const getAllOptimizationEventsImpl = cache(
|
||||||
) AS combined_ids
|
) AS combined_ids
|
||||||
`
|
`
|
||||||
|
|
||||||
const [eventsResult, countResult] = await Promise.all([
|
const [eventsResult, countResult] = await Promise.all([
|
||||||
prisma.$queryRaw<any[]>`
|
prisma.$queryRaw<any[]>`
|
||||||
WITH base_events AS (
|
WITH base_events AS (
|
||||||
SELECT oe.*, r.id as repo_id, r.full_name as repo_full_name, r.name as repo_name
|
SELECT oe.*, r.id as repo_id, r.full_name as repo_full_name, r.name as repo_name
|
||||||
FROM optimization_events oe
|
FROM optimization_events oe
|
||||||
|
|
@ -349,7 +352,7 @@ const getAllOptimizationEventsImpl = cache(
|
||||||
)
|
)
|
||||||
SELECT * FROM base_events
|
SELECT * FROM base_events
|
||||||
`,
|
`,
|
||||||
prisma.$queryRaw<[{ count: bigint }]>`
|
prisma.$queryRaw<[{ count: bigint }]>`
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM optimization_events oe
|
FROM optimization_events oe
|
||||||
LEFT JOIN repositories r ON oe.repository_id = r.id
|
LEFT JOIN repositories r ON oe.repository_id = r.id
|
||||||
|
|
@ -357,64 +360,63 @@ const getAllOptimizationEventsImpl = cache(
|
||||||
${searchCondition}
|
${searchCondition}
|
||||||
${filterConditions}
|
${filterConditions}
|
||||||
`,
|
`,
|
||||||
])
|
])
|
||||||
|
|
||||||
totalCount = Number(countResult[0].count)
|
totalCount = Number(countResult[0].count)
|
||||||
events = eventsResult.map(
|
events = eventsResult.map(
|
||||||
(
|
(
|
||||||
event: Record<string, unknown> & {
|
event: Record<string, unknown> & {
|
||||||
repo_id?: string
|
repo_id?: string
|
||||||
repo_full_name?: string
|
repo_full_name?: string
|
||||||
repo_name?: string
|
repo_name?: string
|
||||||
},
|
},
|
||||||
) => ({
|
) => ({
|
||||||
...event,
|
...event,
|
||||||
repository: event.repo_id
|
repository: event.repo_id
|
||||||
? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name }
|
? { id: event.repo_id, full_name: event.repo_full_name, name: event.repo_name }
|
||||||
: null,
|
: null,
|
||||||
}),
|
}),
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-fetch review data for all events in a single query
|
|
||||||
const traceIds = (events as Array<Record<string, unknown>>).map(
|
|
||||||
(e: Record<string, unknown>) => 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<string, ReviewFeature>(
|
|
||||||
(features as ReviewFeature[]).map((f: ReviewFeature) => [f.trace_id, f]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const eventsWithReviewData = (events as Array<Record<string, unknown>>).map(
|
|
||||||
(event: Record<string, unknown>) => {
|
|
||||||
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<Record<string, unknown>>).map(
|
||||||
|
(e: Record<string, unknown>) => 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<string, ReviewFeature>(
|
||||||
|
(features as ReviewFeature[]).map((f: ReviewFeature) => [f.trace_id, f]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventsWithReviewData = (events as Array<Record<string, unknown>>).map(
|
||||||
|
(event: Record<string, unknown>) => {
|
||||||
|
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(
|
export const getAllOptimizationEvents = withTiming(
|
||||||
"getAllOptimizationEvents",
|
"getAllOptimizationEvents",
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,16 @@
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { connection } from "next/server"
|
import { connection } from "next/server"
|
||||||
import { auth0 } from "@/lib/auth0"
|
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { getUserOrganizations } from "@/components/dashboard/action"
|
|
||||||
import { getAllOptimizationEvents } from "@/app/(dashboard)/review-optimizations/action"
|
import { getAllOptimizationEvents } from "@/app/(dashboard)/review-optimizations/action"
|
||||||
import type { AccountPayload } from "@codeflash-ai/common"
|
import { getActionAccountContext } from "@/lib/server/get-account-context"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
await connection()
|
await connection()
|
||||||
try {
|
try {
|
||||||
const session = await auth0.getSession()
|
const accountContext = await getActionAccountContext()
|
||||||
if (!session?.user?.sub || !session?.user?.nickname) {
|
if (!accountContext) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build AccountPayload from session + org cookie (same as getAccountContext)
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const orgId = cookieStore.get("currentOrganizationId")?.value
|
|
||||||
|
|
||||||
let payload: AccountPayload
|
|
||||||
if (orgId) {
|
|
||||||
const result = await getUserOrganizations(session.user.sub)
|
|
||||||
if (result.success && result.organizations?.some(org => org.id === orgId)) {
|
|
||||||
payload = { orgId }
|
|
||||||
} else {
|
|
||||||
payload = { userId: session.user.sub, username: session.user.nickname }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payload = { userId: session.user.sub, username: session.user.nickname }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
const page = parseInt(searchParams.get("page") || "1", 10)
|
const page = parseInt(searchParams.get("page") || "1", 10)
|
||||||
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10)
|
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10)
|
||||||
|
|
@ -72,7 +53,7 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getAllOptimizationEvents({
|
const data = await getAllOptimizationEvents({
|
||||||
payload,
|
payload: accountContext.payload,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
sort,
|
sort,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,28 @@ import { auth0 } from "@/lib/auth0"
|
||||||
import { getUserOrganizations } from "@/components/dashboard/action"
|
import { getUserOrganizations } from "@/components/dashboard/action"
|
||||||
import type { AccountPayload } from "@codeflash-ai/common"
|
import type { AccountPayload } from "@codeflash-ai/common"
|
||||||
|
|
||||||
|
export interface SessionAccountContext {
|
||||||
|
payload: AccountPayload
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccountPayload(userId: string, username: string): Promise<AccountPayload> {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const orgId = cookieStore.get("currentOrganizationId")?.value
|
||||||
|
|
||||||
|
if (orgId) {
|
||||||
|
// Validate user is a member of this org
|
||||||
|
const result = await getUserOrganizations(userId)
|
||||||
|
if (result.success && result.organizations?.some(org => org.id === orgId)) {
|
||||||
|
return { orgId }
|
||||||
|
}
|
||||||
|
// Invalid org cookie — fall through to personal mode
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId, username }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side utility to determine the current account context (personal or org).
|
* Server-side utility to determine the current account context (personal or org).
|
||||||
* Reads the auth session + org cookie to build an AccountPayload for data fetching.
|
* Reads the auth session + org cookie to build an AccountPayload for data fetching.
|
||||||
|
|
@ -17,17 +39,43 @@ export const getAccountContext = cache(async (): Promise<AccountPayload> => {
|
||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies()
|
return resolveAccountPayload(session.user.sub, session.user.nickname)
|
||||||
const orgId = cookieStore.get("currentOrganizationId")?.value
|
})
|
||||||
|
|
||||||
if (orgId) {
|
/**
|
||||||
// Validate user is a member of this org
|
* Server-action-safe variant that returns the validated account payload plus
|
||||||
const result = await getUserOrganizations(session.user.sub)
|
* the authenticated user identity, or null when there is no active session.
|
||||||
if (result.success && result.organizations?.some(org => org.id === orgId)) {
|
*/
|
||||||
return { orgId }
|
export async function getActionAccountContext(): Promise<SessionAccountContext | null> {
|
||||||
}
|
const session = await auth0.getSession()
|
||||||
// Invalid org cookie — fall through to personal mode
|
|
||||||
|
if (!session?.user?.sub || !session?.user?.nickname) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userId: session.user.sub, username: session.user.nickname }
|
const userId = session.user.sub
|
||||||
|
const username = session.user.nickname
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: await resolveAccountPayload(userId, username),
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionAccountContext = cache(async (): Promise<SessionAccountContext> => {
|
||||||
|
const session = await auth0.getSession()
|
||||||
|
|
||||||
|
if (!session?.user?.sub || !session?.user?.nickname) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.sub
|
||||||
|
const username = session.user.nickname
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: await resolveAccountPayload(userId, username),
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue