Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnrichmentRunDetail, TableDefinition } from '@/lib/table'

const { mockCheckAccess, mockLoadEnrichmentDetail } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockLoadEnrichmentDetail: vi.fn(),
}))

vi.mock('@/lib/table/rows/executions', () => ({
loadEnrichmentDetail: mockLoadEnrichmentDetail,
}))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'denied' }, { status: result.status }),
}
})

import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route'

function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: { columns: [] },
metadata: null,
rowCount: 1,
maxRows: 1_000_000,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
}

function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
const req = new NextRequest(
`http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}`
)
return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) })
}

const detail: EnrichmentRunDetail = {
startedAt: '2026-06-18T00:00:00.000Z',
completedAt: '2026-06-18T00:00:01.000Z',
durationMs: 1000,
totalCost: 0.05,
matchedProvider: 'hunter',
aborted: false,
providers: [
{
id: 'hunter',
label: 'Hunter',
toolId: 'hunter_find_email',
status: 'matched',
cost: 0.05,
durationMs: 1000,
error: null,
},
],
}

describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
})

it('returns the enrichment detail', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(detail)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail } })
expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith(
expect.anything(),
'tbl_1',
'row_1',
'grp_1'
)
})

it('returns null when there is no recorded run', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(null)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail: null } })
})

it('401s when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
const res = await makeRequest()
expect(res.status).toBe(401)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
})

it('denies when access check fails', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
const res = await makeRequest()
expect(res.status).toBe(403)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadEnrichmentDetail } from '@/lib/table/rows/executions'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('EnrichmentDetailAPI')

interface RouteParams {
params: Promise<{ tableId: string; rowId: string; groupId: string }>
}

/**
* GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]
*
* Returns the enrichment cascade breakdown (provider outcomes, cost, timing)
* for one enrichment cell. Read on demand by the enrichment details panel —
* this data is deliberately kept off the hot grid read. Returns `null` for
* cells with no recorded run or runs that predate the feature.
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()

const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(getEnrichmentDetailContract, request, { params })
if (!parsed.success) return parsed.response
const { tableId, rowId, groupId } = parsed.data.params

const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)

const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId)

logger.info(`[${requestId}] Loaded enrichment detail`, {
tableId,
rowId,
groupId,
hasDetail: detail !== null,
})

return NextResponse.json({ success: true, data: { detail } })
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { cn } from '@/lib/core/utils/cn'
import type { TraceSpan } from '@/lib/logs/types'
import {
DEFAULT_BLOCK_COLOR,
adjustBgForContrast,
formatCostAmount,
formatTokenCount,
formatTps,
Expand All @@ -41,6 +41,7 @@ import {
getDisplayName,
hasErrorInTree,
hasUnhandledErrorInTree,
iconColorClass,
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
Expand Down Expand Up @@ -119,31 +120,6 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] {
return kids
}

/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return 'text-white'
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
}

/**
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
* Below the luminance threshold we fall back to the neutral block color used
* for blocks with no distinct identity; everything brighter passes through.
*/
function adjustBgForContrast(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return bgColor
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
return bgColor
}

/**
* Flattens the visible (expanded) span tree into a linear list for keyboard
* navigation, carrying depth, the chain of parent ids for indent drawing, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ export function getBlockIconAndColor(
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
}

/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
export function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return 'text-white'
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
}

/**
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
* Below the luminance threshold we fall back to the neutral block color used
* for blocks with no distinct identity; everything brighter passes through.
*/
export function adjustBgForContrast(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return bgColor
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
return bgColor
}

export function parseTime(value?: string | number | null): number {
if (!value) return 0
const ms = typeof value === 'number' ? value : new Date(value).getTime()
Expand Down
Loading
Loading