Harden dashboard authorization flows

This commit is contained in:
Kevin Turcios 2026-04-13 16:07:39 -05:00
parent 4269ec0275
commit 0fe3ca8c0a
15 changed files with 702 additions and 518 deletions

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { prisma } from "@codeflash-ai/common"
import { getActionAccountContext } from "@/lib/server/get-account-context"
vi.mock("@/lib/server-action-timing", () => ({
withTiming: vi.fn((_name: string, fn: Function) => fn),
@ -9,6 +10,10 @@ vi.mock("@/lib/analytics/tracking", () => ({
trackMemberInvited: vi.fn(),
}))
vi.mock("@/lib/server/get-account-context", () => ({
getActionAccountContext: vi.fn(),
}))
const mockOrg = {
id: "org-1",
organization_members: [
@ -41,6 +46,13 @@ describe("getOrganizationMembers", () => {
let getOrganizationMembers: typeof import("../action").getOrganizationMembers
beforeEach(async () => {
vi.clearAllMocks()
vi.mocked(getActionAccountContext).mockResolvedValue({
payload: { userId: "user-1", username: "testuser" },
userId: "user-1",
username: "testuser",
})
const mod = await import("../action")
getOrganizationMembers = mod.getOrganizationMembers
})
@ -50,7 +62,7 @@ describe("getOrganizationMembers", () => {
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg 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.data).toHaveLength(2)
@ -60,7 +72,7 @@ describe("getOrganizationMembers", () => {
vi.mocked(prisma.organizations.findUnique).mockResolvedValue(mockOrg 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]
expect(member).toEqual({
@ -81,17 +93,32 @@ describe("getOrganizationMembers", () => {
vi.mocked(prisma.organizations.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.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 () => {
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.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.error).toBe("You don't have access to this organization")
@ -102,7 +129,7 @@ describe("getOrganizationMembers", () => {
it("returns error response when Prisma throws", async () => {
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.error).toBe("Connection failed")
@ -111,7 +138,7 @@ describe("getOrganizationMembers", () => {
it("uses fallback message for non-Error exceptions", async () => {
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.error).toBe("Failed to get members")

View file

@ -4,19 +4,27 @@ import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/li
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
import {
deleteOrganizationMemberApiKeys,
getUserById,
organizationMemberRepository,
prisma,
} from "@codeflash-ai/common"
import { Prisma } from "@prisma/client"
import { withTiming } from "@/lib/server-action-timing"
import { trackMemberInvited } from "@/lib/analytics/tracking"
import { getActionAccountContext } from "@/lib/server/get-account-context"
/**
* Get organization members
*/
export const getOrganizationMembers = withTiming(
"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 {
// Check access via indexed composite key in parallel with member fetch
const [org, accessCheck] = await Promise.all([
@ -86,11 +94,17 @@ export const getOrganizationMembers = withTiming(
* Add a member to organization
*/
export async function addOrganizationMember(
currentUserId: string,
invitedUser: GitHubUserSearchResult,
role: UserRole,
organizationId: string,
): Promise<ActionResponse<Member>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
@ -142,15 +156,23 @@ export async function addOrganizationMember(
},
})
// 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, {
invitedUsername: invitedUser.username,
@ -182,11 +204,17 @@ export async function addOrganizationMember(
* Update organization member role
*/
export async function updateOrganizationMemberRole(
currentUserId: string,
organizationId: string,
memberId: string,
newRole: "admin" | "member" | "owner",
): Promise<ActionResponse<Boolean>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
// Fetch only the two specific members we need instead of loading ALL members
const [currentUserMember, targetMember] = await Promise.all([
@ -240,10 +268,16 @@ export async function updateOrganizationMemberRole(
* Remove organization member
*/
export async function removeOrganizationMember(
currentUserId: string,
organizationId: string,
memberId: string,
): Promise<ActionResponse<Boolean>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
// Fetch only the two specific members we need instead of loading ALL members
const [currentUserMember, targetMember] = await Promise.all([
@ -296,9 +330,15 @@ export async function removeOrganizationMember(
* Get current user's role in organization
*/
export async function getCurrentUserRole(
userId: string,
organizationId: string,
): Promise<ActionResponse<{ role: UserRole }>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId } = accountContext
try {
const member = await prisma.organization_members.findUnique({
where: {

View file

@ -1,24 +1,21 @@
"use server"
import { auth0 } from "@/lib/auth0"
import { cookies } from "next/headers"
import { prisma } from "@/lib/prisma"
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.
* Uses @/lib/prisma directly to avoid pulling in @codeflash-ai/common at build time.
*/
export async function getMembersPageInitData() {
const session = await auth0.getSession()
if (!session?.user?.sub) {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return null
}
const userId = session.user.sub
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
const { payload, userId } = accountContext
const orgId = "orgId" in payload ? payload.orgId : null
if (!orgId) {
return { userId, orgId: null, members: [] as Member[], currentUserRole: null }

View file

@ -6,14 +6,13 @@ import { MembersSkeleton } from "@/components/members/MembersSkeleton"
import { GitHubUserSearchResult, Member } from "@/lib/types"
import {
addOrganizationMember,
getCurrentUserRole,
getOrganizationMembers,
updateOrganizationMemberRole,
removeOrganizationMember,
} from "./action"
import { useViewMode } from "@/app/app/ViewModeContext"
import { MembersList } from "@/components/members/members-list"
import { UserSearchModal } from "@/components/members/user-search-modal"
import { getMembersPageInitData } from "./data"
export interface MembersClientProps {
initialUserId: string
@ -65,18 +64,15 @@ export function MembersClient({
setError(null)
try {
const [roleResult, result] = await Promise.all([
getCurrentUserRole(currentUserId, currentOrg.id),
getOrganizationMembers(currentUserId, currentOrg.id),
])
if (roleResult.success && roleResult.data) {
setCurrentUserRole(roleResult.data.role)
}
const initData = await getMembersPageInitData()
if (result.success && result.data) {
setMembers(result.data)
if (!initData || initData.orgId !== currentOrg.id) {
setCurrentUserRole(null)
setMembers([])
setError("Failed to load members")
} else {
setError(result.error || "Failed to load members")
setCurrentUserRole(initData.currentUserRole)
setMembers(initData.members)
}
} catch (err) {
console.error("Failed to fetch members:", err)
@ -85,7 +81,7 @@ export function MembersClient({
setLoading(false)
setIsRefreshing(false)
}
}, [currentOrg?.id, currentUserId, isRefreshing])
}, [currentOrg?.id, isRefreshing])
// Only refetch when org changes from what the server provided
useEffect(() => {
@ -118,7 +114,7 @@ export function MembersClient({
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) {
handleMemberAdded()
}
@ -132,12 +128,7 @@ export function MembersClient({
setError(null)
setSuccess(null)
const result = await updateOrganizationMemberRole(
currentUserId,
currentOrg.id,
memberId,
newRole,
)
const result = await updateOrganizationMemberRole(currentOrg.id, memberId, newRole)
if (result.success) {
setSuccess("Member role updated successfully")
@ -164,7 +155,7 @@ export function MembersClient({
setError(null)
setSuccess(null)
const result = await removeOrganizationMember(currentUserId, currentOrg.id, memberId)
const result = await removeOrganizationMember(currentOrg.id, memberId)
if (result.success) {
setSuccess(`${memberUsername} has been removed successfully`)

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
import { prisma } from "@codeflash-ai/common"
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
import { trackRepositoryConnected } from "@/lib/analytics/tracking"
import { getActionAccountContext } from "@/lib/server/get-account-context"
vi.mock("@/lib/server-action-timing", () => ({
withTiming: vi.fn((_name: string, fn: Function) => fn),
@ -16,6 +17,10 @@ vi.mock("@/lib/analytics/tracking", () => ({
trackRepositoryConnected: vi.fn(),
}))
vi.mock("@/lib/server/get-account-context", () => ({
getActionAccountContext: vi.fn(),
}))
const mockRepo = {
id: "repo-1",
github_repo_id: "12345",
@ -35,11 +40,12 @@ const mockRepo = {
const mockPayload = { userId: "user-1", username: "testuser" }
describe("getRepositoryById", () => {
let getRepositoryById: typeof import("../action").getRepositoryById
let getRepositoryById: typeof import("../queries").getRepositoryByIdForPayload
beforeEach(async () => {
const mod = await import("../action")
getRepositoryById = mod.getRepositoryById
vi.clearAllMocks()
const mod = await import("../queries")
getRepositoryById = mod.getRepositoryByIdForPayload
})
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()
})
})

View file

@ -1,237 +1,25 @@
"use server"
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 { eachDayOfInterval, startOfDay } from "date-fns"
import { GitHubUserSearchResult, Member, UserRole } from "@/lib/types"
import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/lib/action-response"
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
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
}
},
)
import { trackMemberInvited } from "@/lib/analytics/tracking"
import { getActionAccountContext } from "@/lib/server/get-account-context"
export async function addRepositoryMemberById(
currentUserId: string,
repoId: string,
invitedUser: GitHubUserSearchResult,
role: UserRole,
): Promise<ActionResponse> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
const invitedUserId = `github|${invitedUser.githubUserId.toString()}`
@ -280,15 +68,23 @@ export async function addRepositoryMemberById(
user = await createOrUpdateUser(invitedUserId, invitedUser.username, null, null)
}
// Add user to repository members
const newMember = await prisma.repository_members.create({
data: {
repository_id: repoId,
user_id: user.user_id,
role,
added_by: currentUserId,
},
})
let newMember
try {
// Add user to repository members
newMember = await prisma.repository_members.create({
data: {
repository_id: repoId,
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, {
invitedUsername: invitedUser.username,
@ -316,10 +112,14 @@ export async function addRepositoryMemberById(
/**
* Get repository members
*/
export async function getRepositoryMembers(
currentUserId: string,
repoId: string,
): Promise<ActionResponse<Member[]>> {
export async function getRepositoryMembers(repoId: string): Promise<ActionResponse<Member[]>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
// Check access with a single indexed lookup, then fetch members only if authorized
const hasAccess = await prisma.repository_members.findUnique({
@ -372,11 +172,17 @@ export async function getRepositoryMembers(
* Update repository member role
*/
export async function updateRepositoryMemberRole(
currentUserId: string,
repoId: string,
memberId: string,
newRole: UserRole,
): Promise<ActionResponse<Boolean>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
// Fetch only the two specific members we need instead of loading ALL repository members
const [currentUserMember, targetMember] = await Promise.all([
@ -429,10 +235,16 @@ export async function updateRepositoryMemberRole(
* Remove repository member
*/
export async function removeRepositoryMember(
currentUserId: string,
repoId: string,
memberId: string,
): Promise<ActionResponse<Boolean>> {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return createErrorResponse("Unauthorized")
}
const { userId: currentUserId } = accountContext
try {
// Fetch only the two specific members we need instead of loading ALL repository members
const [currentUserMember, targetMember] = await Promise.all([

View file

@ -1,36 +1,32 @@
"use server"
import { auth0 } from "@/lib/auth0"
import { getAccountContext } from "@/lib/server/get-account-context"
import { getActionAccountContext } from "@/lib/server/get-account-context"
import {
getRepositoryById,
getActiveUserLeaderboardLast30DaysForRepo,
getOptimizationCountsByRepo,
getOptimizationsTimeSeriesData,
getPullRequestEventTimeSeriesData,
getActiveUserLeaderboardLast30DaysForRepo,
} from "./action"
getRepositoryByIdForPayload,
} from "./queries"
/**
* Server-side function to fetch all data needed for the repository detail page
* in parallel. Eliminates the client-side authrepostats waterfall.
*/
export async function getRepoDetailInitData(repositoryId: string) {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
export async function getRepoDetailInitData(repositoryId: string, selectedPrYear?: number) {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return null
}
const userId = session.user.sub
const username = session.user.nickname
const { payload, userId } = accountContext
const payload = await getAccountContext()
const repository = await getRepositoryById(payload, repositoryId)
const repository = await getRepositoryByIdForPayload(payload, repositoryId)
if (!repository) {
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
// Use the combined count query (single SQL) instead of two separate COUNT calls
@ -39,7 +35,7 @@ export async function getRepoDetailInitData(repositoryId: string) {
getOptimizationCountsByRepo(repositoryId),
getOptimizationsTimeSeriesData(repositoryId, false),
getOptimizationsTimeSeriesData(repositoryId, true),
getPullRequestEventTimeSeriesData(currentYear, repositoryId),
getPullRequestEventTimeSeriesData(prYear, repositoryId),
getActiveUserLeaderboardLast30DaysForRepo(repositoryId),
])
@ -68,7 +64,6 @@ export async function getRepoDetailInitData(repositoryId: string) {
return {
userId,
username,
orgId: "orgId" in payload ? payload.orgId : null,
repository,
stats: {
@ -80,7 +75,22 @@ export async function getRepoDetailInitData(repositoryId: string) {
successfulOptimizationsTrendDates,
prActivityData: Array.isArray(prData) ? prData : [],
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)
}

View file

@ -39,7 +39,6 @@ export default async function RepositoryDetailPage({
<RepoDetailClient
repositoryId={repositoryId}
initialUserId={initData.userId}
initialUsername={initData.username}
initialOrgId={initData.orgId ?? null}
initialRepository={initData.repository as any}
initialStats={initData.stats}

View file

@ -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
}
},
)

View file

@ -21,16 +21,12 @@ import { RepositoryDetailSkeleton } from "@/components/repositories/RepositoryDe
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import {
getActiveUserLeaderboardLast30DaysForRepo,
getOptimizationsTimeSeriesData,
getPullRequestEventTimeSeriesData,
getRepositoryById,
getOptimizationCountsByRepo,
getRepositoryMembers,
updateRepositoryMemberRole,
removeRepositoryMember,
addRepositoryMemberById,
} from "./action"
import { getRepoDetailInitData, getRepoDetailPrActivityData } from "./data"
import { GitHubUserSearchResult, Member } from "@/lib/types"
import { RepositoryWithUsage } from "@/app/(dashboard)/dashboard/action"
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 { RoleSelector } from "@/components/members/role-selector"
import { ConfirmDialog } from "@/components/confirm-dialog"
import type { AccountPayload } from "@codeflash-ai/common"
// Repository Header Component
const RepositoryHeader = ({ repository }: { repository: RepositoryWithUsage }) => {
@ -285,7 +280,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
}
setError(null)
const result = await getRepositoryMembers(currentUserId, repoId)
const result = await getRepositoryMembers(repoId)
if (result.success && result.data) {
setMembers(result.data)
@ -295,7 +290,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
setLoading(false)
setIsRefreshing(false)
}, [currentUserId, repoId, isRefreshing])
}, [repoId, isRefreshing])
useEffect(() => {
fetchMembers()
@ -315,7 +310,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
}
const handleUserAdd = async (user: GitHubUserSearchResult) => {
const result = await addRepositoryMemberById(currentUserId, repoId, user, selectedRole)
const result = await addRepositoryMemberById(repoId, user, selectedRole)
if (result.success) {
handleMemberAdded()
}
@ -327,7 +322,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
setError(null)
setSuccess(null)
const result = await updateRepositoryMemberRole(currentUserId, repoId, memberId, newRole)
const result = await updateRepositoryMemberRole(repoId, memberId, newRole)
if (result.success) {
setSuccess("Member role updated successfully")
@ -353,7 +348,7 @@ const MembersTab = ({ repoId, currentUserId }: { repoId: string; currentUserId:
setError(null)
setSuccess(null)
const result = await removeRepositoryMember(currentUserId, repoId, memberId)
const result = await removeRepositoryMember(repoId, memberId)
if (result.success) {
setSuccess(`${memberUsername} has been removed successfully`)
@ -485,7 +480,6 @@ export interface RepoDetailStats {
export interface RepoDetailClientProps {
repositoryId: string
initialUserId: string
initialUsername: string
initialOrgId: string | null
initialRepository: RepositoryWithUsage
initialStats: RepoDetailStats
@ -494,7 +488,6 @@ export interface RepoDetailClientProps {
export function RepoDetailClient({
repositoryId,
initialUserId,
initialUsername,
initialOrgId,
initialRepository,
initialStats,
@ -574,63 +567,23 @@ export function RepoDetailClient({
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000))
}
const payload: AccountPayload = currentOrg
? { orgId: currentOrg.id }
: { userId: currentUserId, username: initialUsername }
const initData = await getRepoDetailInitData(repositoryId, selectedPrYear)
const currentRepo = await getRepositoryById(payload, repositoryId)
if (!currentRepo) {
if (!initData?.repository || !initData.stats) {
throw new Error("Repository not found")
}
setRepository(currentRepo)
// Fetch all statistics in parallel - these are all independent queries
// Use the combined count query (single SQL) instead of two separate COUNT calls
const [
counts,
optimizationsOverTime,
successfulOptimizationsOverTime,
prData,
leaderboardData,
] = await Promise.all([
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 })
setRepository(initData.repository)
setOptimizationStats({
totalAttempts: initData.stats.totalAttempts,
successfulAttempts: initData.stats.successfulAttempts,
})
setOptimizationsTrend(initData.stats.optimizationsTrend)
setOptimizationsTrendDates(initData.stats.optimizationsTrendDates)
setSuccessfulOptimizationsTrend(initData.stats.successfulOptimizationsTrend)
setSuccessfulOptimizationsTrendDates(initData.stats.successfulOptimizationsTrendDates)
setPrActivityData(initData.stats.prActivityData)
setActiveUsersData(initData.stats.activeUsersData)
setRetryCount(0)
} catch (err) {
console.error(`Failed to fetch repository data (attempt ${attempt + 1}):`, err)
@ -656,7 +609,7 @@ export function RepoDetailClient({
setLoading(false)
}
},
[maxRetries, selectedPrYear, repositoryId, currentOrg, currentUserId],
[maxRetries, selectedPrYear, repositoryId],
)
// Only refetch when org changes from what the server provided, or when prYear changes
@ -672,8 +625,10 @@ export function RepoDetailClient({
useEffect(() => {
if (selectedPrYear === initialPrYearRef.current) return
initialPrYearRef.current = selectedPrYear
getPullRequestEventTimeSeriesData(selectedPrYear, repositoryId).then(prData => {
setPrActivityData(Array.isArray(prData) ? prData : [])
getRepoDetailPrActivityData(repositoryId, selectedPrYear).then(prData => {
if (prData) {
setPrActivityData(Array.isArray(prData) ? prData : [])
}
})
}, [selectedPrYear, repositoryId])

View file

@ -7,7 +7,7 @@ import { auth0 } from "@/lib/auth0"
import { AccountPayload, buildOptimizationOrCondition, prisma } from "@codeflash-ai/common"
import * as Sentry from "@sentry/nextjs"
import { trackOptimizationReviewed } from "@/lib/analytics/tracking"
import { getAccountContext } from "@/lib/server/get-account-context"
import { getActionAccountContext } from "@/lib/server/get-account-context"
export interface DiffContent {
oldContent: string
@ -32,7 +32,67 @@ export interface GetStagingCodeParams {
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,
): Promise<ActionResponse<StagingCodeResponse>> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
@ -95,6 +155,16 @@ export async function commitStagingCode(
fileChanges: Record<string, string>,
commitMessage?: string,
): 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 session = await auth0.getAccessToken()
@ -148,7 +218,7 @@ export async function commitStagingCode(
}
}
export async function getOptimizationEventById({
async function getOptimizationEventById({
payload,
trace_id,
}: {
@ -206,23 +276,29 @@ export async function saveOptimizationChanges({
filePath,
newContent,
}: {
userId: string
eventId: string
filePath: string
newContent: string
}) {
try {
const currentEvent = await prisma.optimization_events.findUnique({
where: { id: eventId },
select: { metadata: true },
})
if (!currentEvent) {
throw new Error("Event not found")
const authorizedEvent = await getAuthorizedEventById(eventId, {
select: { id: true, metadata: true },
})
if (!authorizedEvent) {
return {
success: false,
error: "Unauthorized",
}
}
if (!authorizedEvent.event) {
return {
success: false,
error: "Event not found",
}
}
try {
// Get the current metadata
const currentMetadata = (currentEvent.metadata as any) || {}
const currentMetadata = (authorizedEvent.event.metadata as any) || {}
const currentDiffContents = currentMetadata.diffContents || {}
// Update only the specific file's content
@ -292,6 +368,30 @@ export async function createPullRequest({
originalLineProfiler?: string
optimizedLineProfiler?: string
}): 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
) {
return createErrorResponse("Repository mismatch for optimization event")
}
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await auth0.getAccessToken()
@ -395,6 +495,16 @@ export async function createPullRequest({
}
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 {
const updated = await prisma.optimization_events.update({
where: {
@ -411,20 +521,22 @@ export async function setApprovalStatus(eventId: string, status: "approved" | "r
}
}
export async function addComment({
eventId,
userId,
content,
}: {
eventId: string
userId: string
content: string
}) {
export async function addComment({ eventId, content }: { eventId: string; content: 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 {
const comment = await prisma.comments.create({
data: {
optimization_event_id: eventId,
author_user_id: userId,
author_user_id: authorizedEvent.accountContext.userId,
content,
},
})
@ -439,6 +551,22 @@ export async function addComment({
}
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 {
const comments = await prisma.comments.findMany({
where: {
@ -474,21 +602,18 @@ export async function getCommentsByEvent(eventId: string) {
* Called from the server component to eliminate the client-side data-fetching waterfall.
*/
export async function getReviewPageInitData(traceId: string) {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
const accountContext = await getActionAccountContext()
if (!accountContext) {
return null
}
const userId = session.user.sub
const username = session.user.nickname
// Use validated account context (verifies org membership if cookie is set)
const payload = await getAccountContext()
// Fetch the optimization event
const event = await getOptimizationEventById({ payload, trace_id: traceId })
const event = await getOptimizationEventById({
payload: accountContext.payload,
trace_id: traceId,
})
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
@ -511,8 +636,6 @@ export async function getReviewPageInitData(traceId: string) {
])
return {
userId,
username,
event,
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
stagingCode: stagingCodeResult.success ? (stagingCodeResult.data ?? null) : null,
@ -524,8 +647,6 @@ export async function getReviewPageInitData(traceId: string) {
const commentsResult = await getCommentsByEvent(event.id)
return {
userId,
username,
event,
comments: commentsResult.success ? (commentsResult.comments ?? []) : [],
stagingCode: null,

View file

@ -24,8 +24,6 @@ export default async function OptimizationReviewPage({ params }: ReviewPageProps
return (
<OptimizationReviewClient
traceId={traceId}
initialUserId={initData.userId}
initialUsername={initData.username}
initialEvent={initData.event as any}
initialComments={initData.comments as any}
initialStagingCode={initData.stagingCode as any}

View file

@ -14,13 +14,12 @@ import {
} from "lucide-react"
import {
createPullRequest,
getOptimizationEventById,
saveOptimizationChanges,
setApprovalStatus,
addComment,
getCommentsByEvent,
getStagingCodeFromApi,
commitStagingCode,
getReviewPageInitData,
type StagingCodeResponse,
} from "./action"
import dynamic from "next/dynamic"
@ -151,8 +150,6 @@ interface SaveOptimizationResult {
export interface ReviewClientProps {
traceId: string
initialUserId: string
initialUsername: string
initialEvent: RawOptimizationEvent | null
initialComments: Comment[]
initialStagingCode: StagingCodeResponse | null
@ -196,8 +193,6 @@ function transformEvent(
export function OptimizationReviewClient({
traceId,
initialUserId,
initialUsername,
initialEvent,
initialComments,
initialStagingCode,
@ -208,7 +203,6 @@ export function OptimizationReviewClient({
)
const [loading, setLoading] = useState(!initialEvent)
const [creatingPR, setCreatingPR] = useState(false)
const [userId] = useState<string>(initialUserId)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
@ -227,7 +221,7 @@ export function OptimizationReviewClient({
// State for base branch dialog
const [showBaseBranchDialog, setShowBaseBranchDialog] = useState(false)
const currentOrgId = currentOrg?.id
const currentOrgId = currentOrg?.id ?? null
// Track the org ID used for the server-prefetched data
const initialOrgIdRef = useRef(currentOrgId)
@ -237,6 +231,7 @@ export function OptimizationReviewClient({
if (currentOrgId === initialOrgIdRef.current) {
return
}
initialOrgIdRef.current = currentOrgId
// Prevent concurrent calls
if (isLoadingRef.current) {
return
@ -246,62 +241,18 @@ export function OptimizationReviewClient({
isLoadingRef.current = true
setLoading(true)
try {
const data = await getOptimizationEventById({
payload: currentOrgId
? { orgId: currentOrgId }
: { userId: initialUserId, username: initialUsername },
trace_id: traceId,
})
if (data) {
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)
}
const data = await getReviewPageInitData(traceId)
if (data?.event) {
setComments((data.comments as Comment[]) ?? [])
setEvent(
transformEvent(
data.event as unknown as RawOptimizationEvent,
(data.stagingCode as StagingCodeResponse | null | undefined) ?? null,
),
)
} else {
setEvent(null)
setComments([])
}
} catch (error) {
console.error("Failed to load optimization event:", error)
@ -312,7 +263,7 @@ export function OptimizationReviewClient({
}
}
refetchEvent()
}, [currentOrgId, traceId, initialUserId, initialUsername])
}, [currentOrgId, traceId])
const loadComments = async (eventId: string) => {
setLoadingComments(true)
@ -396,7 +347,7 @@ export function OptimizationReviewClient({
// Handle autosave edits with database persistence (only for plain_text storage)
const handleEdit = useCallback(
async (filePath: string, newContent: string) => {
if (!event || !userId) return
if (!event) return
// Skip autosave for git_branch storage - use manual commit instead
if (event.staging_storage_type === "git_branch") {
@ -412,7 +363,6 @@ export function OptimizationReviewClient({
const timeoutId = setTimeout(async () => {
try {
const result = (await saveOptimizationChanges({
userId,
eventId: event.id,
filePath,
newContent,
@ -451,11 +401,11 @@ export function OptimizationReviewClient({
console.error("Error in handleEdit:", error)
}
},
[event, userId],
[event],
)
const handleSubmitReview = async (status: "approved" | "rejected") => {
if (!event || !userId) return
if (!event) return
setIsUpdatingStatus(true)
@ -475,13 +425,12 @@ export function OptimizationReviewClient({
}
const handleAddComment = async () => {
if (!event || !userId || !newComment.trim()) return
if (!event || !newComment.trim()) return
setIsSubmittingComment(true)
try {
const commentResult = await addComment({
eventId: event.id,
userId,
content: newComment.trim(),
})

View file

@ -1,35 +1,16 @@
import { NextRequest, NextResponse } 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 type { AccountPayload } from "@codeflash-ai/common"
import { getActionAccountContext } from "@/lib/server/get-account-context"
export async function GET(request: NextRequest) {
await connection()
try {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
const accountContext = await getActionAccountContext()
if (!accountContext) {
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 page = parseInt(searchParams.get("page") || "1", 10)
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10)
@ -72,7 +53,7 @@ export async function GET(request: NextRequest) {
}
const data = await getAllOptimizationEvents({
payload,
payload: accountContext.payload,
search: search || undefined,
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort,

View file

@ -5,6 +5,28 @@ import { auth0 } from "@/lib/auth0"
import { getUserOrganizations } from "@/components/dashboard/action"
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).
* 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")
}
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
return resolveAccountPayload(session.user.sub, session.user.nickname)
})
if (orgId) {
// Validate user is a member of this org
const result = await getUserOrganizations(session.user.sub)
if (result.success && result.organizations?.some(org => org.id === orgId)) {
return { orgId }
}
// Invalid org cookie — fall through to personal mode
/**
* Server-action-safe variant that returns the validated account payload plus
* the authenticated user identity, or null when there is no active session.
*/
export async function getActionAccountContext(): Promise<SessionAccountContext | null> {
const session = await auth0.getSession()
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,
}
})