import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { addWorkflowGroupContract, deleteWorkflowGroupContract, updateWorkflowGroupContract, } 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 { addWorkflowGroup, deleteWorkflowGroup, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableWorkflowGroupsAPI') interface RouteParams { params: Promise<{ tableId: string }> } /** * Confirms `workflowId` resolves to an active workflow in `workspaceId` before it is * persisted onto a table's workflow group. Returns a 400 response when the workflow * doesn't exist or belongs to a different workspace, otherwise `null`. */ async function validateWorkflowInWorkspace( workflowId: string, workspaceId: string ): Promise { const context = await getActiveWorkflowContext(workflowId) if (!context || context.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }) } return null } /** * Maps known service-layer error messages onto HTTP responses; falls through * to a 500 with a generic message for anything unrecognized. The three * group-route handlers all surface the same error shapes from * `addWorkflowGroup` / `updateWorkflowGroup` / `deleteWorkflowGroup`, so they * share this mapper instead of repeating the if-chain three times. */ function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { if (error instanceof Error) { const msg = error.message if (msg === 'Table not found' || msg.includes('not found')) { return NextResponse.json({ error: msg }, { status: 404 }) } if ( msg.includes('Schema validation') || msg.includes('Missing column definition') || msg.includes('already exists') || msg.includes('exceed') ) { return NextResponse.json({ error: msg }, { status: 400 }) } } logger.error(fallbackMessage, error) return NextResponse.json({ error: fallbackMessage }, { status: 500 }) } /** POST /api/table/[tableId]/groups — create a workflow group + its output columns. */ export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const parsed = await parseRequest(addWorkflowGroupContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== validated.workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } if (validated.group.workflowId) { const workflowError = await validateWorkflowInWorkspace( validated.group.workflowId, result.table.workspaceId ) if (workflowError) return workflowError } const updatedTable = await addWorkflowGroup( { tableId, group: validated.group, outputColumns: validated.outputColumns, autoRun: validated.autoRun, actorUserId: authResult.userId, }, requestId ) return NextResponse.json({ success: true, data: { columns: updatedTable.schema.columns.map(normalizeColumn), workflowGroups: updatedTable.schema.workflowGroups ?? [], }, }) } catch (error) { return mapWorkflowGroupError(error, 'Failed to add workflow group') } }) /** PATCH /api/table/[tableId]/groups — update a workflow group (deps / outputs). */ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const parsed = await parseRequest(updateWorkflowGroupContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== validated.workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } if (validated.workflowId !== undefined) { const workflowError = await validateWorkflowInWorkspace( validated.workflowId, result.table.workspaceId ) if (workflowError) return workflowError } const updatedTable = await updateWorkflowGroup( { tableId, groupId: validated.groupId, actorUserId: authResult.userId, ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), ...(validated.name !== undefined ? { name: validated.name } : {}), ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), ...(validated.newOutputColumns !== undefined ? { newOutputColumns: validated.newOutputColumns } : {}), ...(validated.mappingUpdates !== undefined ? { mappingUpdates: validated.mappingUpdates } : {}), ...(validated.inputMappings !== undefined ? { inputMappings: validated.inputMappings } : {}), ...(validated.deploymentMode !== undefined ? { deploymentMode: validated.deploymentMode } : {}), ...(validated.type !== undefined ? { type: validated.type } : {}), ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, requestId ) return NextResponse.json({ success: true, data: { columns: updatedTable.schema.columns.map(normalizeColumn), workflowGroups: updatedTable.schema.workflowGroups ?? [], }, }) } catch (error) { return mapWorkflowGroupError(error, 'Failed to update workflow group') } }) /** DELETE /api/table/[tableId]/groups — remove a workflow group + its columns. */ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const parsed = await parseRequest(deleteWorkflowGroupContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== validated.workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } const updatedTable = await deleteWorkflowGroup( { tableId, groupId: validated.groupId }, requestId ) return NextResponse.json({ success: true, data: { columns: updatedTable.schema.columns.map(normalizeColumn), workflowGroups: updatedTable.schema.workflowGroups ?? [], }, }) } catch (error) { return mapWorkflowGroupError(error, 'Failed to delete workflow group') } })