import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchInsertRows, CSV_MAX_BATCH_SIZE, CSV_MAX_FILE_SIZE_BYTES, CSV_SCHEMA_SAMPLE_SIZE, coerceRowsForTable, createCsvParser, createTable, deleteTable, getWorkspaceTableLimits, inferSchemaFromCsv, sanitizeName, TABLE_LIMITS, type TableDefinition, type TableSchema, } from '@/lib/table' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { csvProxyBodyCapResponse, multipartErrorResponse, normalizeColumn, rowWriteErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const maxDuration = 300 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() let fileStream: Readable | undefined try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const userId = authResult.userId const oversize = csvProxyBodyCapResponse(request) if (oversize) return oversize let parsed: Awaited> try { parsed = await readMultipart(request, { maxFileBytes: CSV_MAX_FILE_SIZE_BYTES, requiredFieldsBeforeFile: ['workspaceId'], signal: request.signal, }) } catch (err) { if (isMultipartError(err)) return multipartErrorResponse(err) throw err } const { fields, file } = parsed if (!file) { return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) } fileStream = file.stream const workspaceIdResult = csvImportFormSchema.shape.workspaceId.safeParse(fields.workspaceId) if (!workspaceIdResult.success) { return NextResponse.json( { error: getValidationErrorMessage(workspaceIdResult.error) }, { status: 400 } ) } const workspaceId = workspaceIdResult.data const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } let timezone = (await getUserSettings(userId)).timezone ?? 'UTC' if (fields.timezone) { const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone) if (!timezoneResult.success) { return NextResponse.json( { error: getValidationErrorMessage(timezoneResult.error) }, { status: 400 } ) } timezone = timezoneResult.data } const ext = file.filename.split('.').pop()?.toLowerCase() const extensionResult = csvExtensionSchema.safeParse(ext) if (!extensionResult.success) { return NextResponse.json( { error: getValidationErrorMessage(extensionResult.error) }, { status: 400 } ) } const delimiter = extensionResult.data === 'tsv' ? '\t' : ',' const parser = createCsvParser(delimiter) // `.pipe` doesn't forward source errors; forward them so the iterator throws. file.stream.on('error', (err) => parser.destroy(err)) file.stream.pipe(parser) interface ImportState { table: TableDefinition schema: TableSchema headerToColumn: Map } const insertRows = async ( rows: Record[], state: ImportState, currentRowCount: number ) => { if (rows.length === 0) return 0 const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) const result = await batchInsertRows( { tableId: state.table.id, rows: coerced, workspaceId, userId }, // The created table's rowCount is frozen at 0; pass the running total so the // per-batch capacity check sees cumulative rows, not an always-empty table. { ...state.table, rowCount: currentRowCount }, generateId().slice(0, 8) ) return result.length } /** Infer the schema from the buffered sample and create the (empty) table. */ const buildTable = async (sampleRows: Record[]): Promise => { const inferred = inferSchemaFromCsv(Object.keys(sampleRows[0]), sampleRows) const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } const planLimits = await getWorkspaceTableLimits(workspaceId) const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( 0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH ) const table = await createTable( { name: tableName, description: `Imported from ${file.filename}`, schema, workspaceId, userId, maxTables: planLimits.maxTables, }, requestId ) // Coerce against the *created* schema so rows key by the ids `createTable` // assigned (the local `schema` is the id-less inferred one). return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } } let state: ImportState | null = null let inserted = 0 const sample: Record[] = [] let batch: Record[] = [] try { for await (const record of parser as AsyncIterable>) { if (!state) { sample.push(record) if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { state = await buildTable(sample) inserted += await insertRows(sample, state, inserted) } continue } batch.push(record) if (batch.length >= CSV_MAX_BATCH_SIZE) { inserted += await insertRows(batch, state, inserted) batch = [] } } if (!state) { if (sample.length === 0) { return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) } state = await buildTable(sample) inserted += await insertRows(sample, state, inserted) } else { inserted += await insertRows(batch, state, inserted) } } catch (streamError) { if (state) await deleteTable(state.table.id, requestId).catch(() => {}) throw streamError } logger.info(`[${requestId}] CSV imported`, { tableId: state.table.id, fileName: file.filename, columns: state.schema.columns.length, rows: inserted, }) return NextResponse.json({ success: true, data: { table: { id: state.table.id, name: state.table.name, description: state.table.description, schema: state.schema, rowCount: inserted, }, }, }) } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import failed:`, error) // Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason. const rowWriteError = rowWriteErrorResponse(error) if (rowWriteError) return rowWriteError const message = toError(error).message const isClientError = message.includes('maximum table limit') || message.includes('CSV file has no') || message.includes('Invalid table name') || message.includes('Invalid schema') || message.includes('already exists') return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, { status: isClientError ? 400 : 500 } ) } finally { fileStream?.destroy() } })