diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2c1bcb62bf..e826d0c17b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4679,6 +4679,26 @@ export function BedrockIcon(props: SVGProps) { ) } +export function TableIcon(props: SVGProps) { + return ( + + + + + + + + ) +} export function ReductoIcon(props: SVGProps) { return ( = { stripe: StripeIcon, stt: STTIcon, supabase: SupabaseIcon, + table: TableIcon, tavily: TavilyIcon, telegram: TelegramIcon, tinybird: TinybirdIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index ec3178013b..79e9fbd153 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -104,6 +104,7 @@ "stripe", "stt", "supabase", + "table", "tavily", "telegram", "tinybird", diff --git a/apps/docs/content/docs/en/tools/table.mdx b/apps/docs/content/docs/en/tools/table.mdx new file mode 100644 index 0000000000..acde8e300c --- /dev/null +++ b/apps/docs/content/docs/en/tools/table.mdx @@ -0,0 +1,351 @@ +--- +title: Table +description: User-defined data tables for storing and querying structured data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations. + +**Why Use Tables?** +- **No external setup**: Create tables instantly without configuring external databases +- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace +- **Flexible schema**: Define columns with types (string, number, boolean, date, json) and constraints (required, unique) +- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators +- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval + +**Key Features:** +- Create tables with custom schemas +- Insert, update, upsert, and delete rows +- Query with filters and sorting +- Batch operations for bulk inserts +- Bulk updates and deletes by filter +- Up to 10,000 rows per table, 100 tables per workspace + +## Creating Tables + +Tables are created from the **Tables** section in the sidebar. Each table requires: +- **Name**: Alphanumeric with underscores (e.g., `customer_leads`) +- **Description**: Optional description of the table's purpose +- **Schema**: Define columns with name, type, and optional constraints + +### Column Types + +| Type | Description | Example Values | +|------|-------------|----------------| +| `string` | Text data | `"John Doe"`, `"active"` | +| `number` | Numeric data | `42`, `99.99` | +| `boolean` | True/false values | `true`, `false` | +| `date` | Date/time values | `"2024-01-15T10:30:00Z"` | +| `json` | Complex nested data | `{"address": {"city": "NYC"}}` | + +### Column Constraints + +- **Required**: Column must have a value (cannot be null) +- **Unique**: Values must be unique across all rows (enables upsert matching) + +## Usage Instructions + +Create and manage custom data tables. Store, query, and manipulate structured data within workflows. + +## Tools + +### `table_query_rows` + +Query rows from a table with filtering, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `filter` | object | No | Filter conditions using MongoDB-style operators | +| `sort` | object | No | Sort order as \{column: "asc"\|"desc"\} | +| `limit` | number | No | Maximum rows to return \(default: 100, max: 1000\) | +| `offset` | number | No | Number of rows to skip \(default: 0\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether query succeeded | +| `rows` | array | Query result rows | +| `rowCount` | number | Number of rows returned | +| `totalCount` | number | Total rows matching filter | +| `limit` | number | Limit used in query | +| `offset` | number | Offset used in query | + +### `table_insert_row` + +Insert a new row into a table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `data` | object | Yes | Row data as JSON object matching the table schema | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether row was inserted | +| `row` | object | Inserted row data including generated ID | +| `message` | string | Status message | + +### `table_upsert_row` + +Insert or update a row based on unique column constraints. If a row with matching unique field exists, update it; otherwise insert a new row. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `data` | object | Yes | Row data to insert or update | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether row was upserted | +| `row` | object | Upserted row data | +| `operation` | string | Operation performed: "insert" or "update" | +| `message` | string | Status message | + +### `table_batch_insert_rows` + +Insert multiple rows at once (up to 1000 rows per batch) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `rows` | array | Yes | Array of row data objects to insert | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether batch insert succeeded | +| `rows` | array | Array of inserted rows with IDs | +| `insertedCount` | number | Number of rows inserted | +| `message` | string | Status message | + +### `table_update_row` + +Update a specific row by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `rowId` | string | Yes | Row ID to update | +| `data` | object | Yes | Data to update \(partial update supported\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether row was updated | +| `row` | object | Updated row data | +| `message` | string | Status message | + +### `table_update_rows_by_filter` + +Update multiple rows matching a filter condition + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `filter` | object | Yes | Filter to match rows for update | +| `data` | object | Yes | Data to apply to matching rows | +| `limit` | number | No | Maximum rows to update \(default: 1000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether update succeeded | +| `updatedCount` | number | Number of rows updated | +| `updatedRowIds` | array | IDs of updated rows | +| `message` | string | Status message | + +### `table_delete_row` + +Delete a specific row by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `rowId` | string | Yes | Row ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether row was deleted | +| `deletedCount` | number | Number of rows deleted \(1 or 0\) | +| `message` | string | Status message | + +### `table_delete_rows_by_filter` + +Delete multiple rows matching a filter condition + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `filter` | object | Yes | Filter to match rows for deletion | +| `limit` | number | No | Maximum rows to delete \(default: 1000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether delete succeeded | +| `deletedCount` | number | Number of rows deleted | +| `deletedRowIds` | array | IDs of deleted rows | +| `message` | string | Status message | + +### `table_get_row` + +Get a single row by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | +| `rowId` | string | Yes | Row ID to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether row was found | +| `row` | object | Row data | +| `message` | string | Status message | + +### `table_get_schema` + +Get the schema definition for a table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tableId` | string | Yes | Table ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether schema was retrieved | +| `name` | string | Table name | +| `columns` | array | Array of column definitions | +| `message` | string | Status message | + +## Filter Operators + +Filters use MongoDB-style operators for flexible querying: + +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` | +| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` | +| `$gt` | Greater than | `{"age": {"$gt": 18}}` | +| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` | +| `$lt` | Less than | `{"price": {"$lt": 100}}` | +| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` | +| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` | +| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` | +| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` | + +### Combining Filters + +Multiple field conditions are combined with AND logic: + +```json +{ + "status": "active", + "age": {"$gte": 18} +} +``` + +Use `$or` for OR logic: + +```json +{ + "$or": [ + {"status": "active"}, + {"status": "pending"} + ] +} +``` + +## Sort Specification + +Specify sort order with column names and direction: + +```json +{ + "createdAt": "desc" +} +``` + +Multi-column sorting: + +```json +{ + "priority": "desc", + "name": "asc" +} +``` + +## Built-in Columns + +Every row automatically includes: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique row identifier | +| `createdAt` | date | When the row was created | +| `updatedAt` | date | When the row was last modified | + +These can be used in filters and sorting. + +## Limits + +| Resource | Limit | +|----------|-------| +| Tables per workspace | 100 | +| Rows per table | 10,000 | +| Columns per table | 50 | +| Max row size | 100KB | +| String value length | 10,000 characters | +| Query limit | 1,000 rows | +| Batch insert size | 1,000 rows | +| Bulk update/delete | 1,000 rows | + +## Notes + +- Category: `blocks` +- Type: `table` +- Tables are scoped to workspaces and accessible from any workflow within that workspace +- Data persists across workflow executions +- Use unique constraints to enable upsert functionality +- The visual filter/sort builder provides an easy way to construct queries without writing JSON diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts new file mode 100644 index 0000000000..a39e0bc187 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -0,0 +1,138 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { deleteTable, type TableSchema } from '@/lib/table' +import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils' + +const logger = createLogger('TableDetailAPI') + +const GetTableSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), +}) + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** GET /api/table/[tableId] - Retrieves a single table's details. */ +export async function GET(request: NextRequest, { params }: TableRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized table access attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const validated = GetTableSchema.parse({ + workspaceId: searchParams.get('workspaceId'), + }) + + const result = await checkAccess(tableId, authResult.userId, 'read') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`) + + const schemaData = table.schema as TableSchema + + return NextResponse.json({ + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), + }, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error getting table:`, error) + return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) + } +} + +/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */ +export async function DELETE(request: NextRequest, { params }: TableRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized table delete attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const validated = GetTableSchema.parse({ + workspaceId: searchParams.get('workspaceId'), + }) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + await deleteTable(tableId, requestId) + + return NextResponse.json({ + success: true, + data: { + message: 'Table deleted successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error deleting table:`, error) + return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts new file mode 100644 index 0000000000..19e3411474 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,276 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import type { RowData, TableSchema } from '@/lib/table' +import { validateRowData } from '@/lib/table' +import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils' + +const logger = createLogger('TableRowAPI') + +const GetRowSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), +}) + +const UpdateRowSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + data: z.record(z.unknown(), { required_error: 'Row data is required' }), +}) + +const DeleteRowSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), +}) + +interface RowRouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */ +export async function GET(request: NextRequest, { params }: RowRouteParams) { + const requestId = generateRequestId() + const { tableId, rowId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const validated = GetRowSchema.parse({ + workspaceId: searchParams.get('workspaceId'), + }) + + const result = await checkAccess(tableId, authResult.userId, 'read') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const [row] = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId) + ) + ) + .limit(1) + + if (!row) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + + logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: row.data, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error getting row:`, error) + return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) + } +} + +/** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */ +export async function PATCH(request: NextRequest, { params }: RowRouteParams) { + const requestId = generateRequestId() + const { tableId, rowId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const validated = UpdateRowSchema.parse(body) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + // Fetch existing row to support partial updates + const [existingRow] = await db + .select({ data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId) + ) + ) + .limit(1) + + if (!existingRow) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + + // Merge existing data with incoming partial data (incoming takes precedence) + const mergedData = { + ...(existingRow.data as RowData), + ...(validated.data as RowData), + } + + const validation = await validateRowData({ + rowData: mergedData, + schema: table.schema as TableSchema, + tableId, + excludeRowId: rowId, + }) + if (!validation.valid) return validation.response + + const now = new Date() + + const [updatedRow] = await db + .update(userTableRows) + .set({ + data: mergedData, + updatedAt: now, + }) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId) + ) + ) + .returning() + + if (!updatedRow) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + + logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + row: { + id: updatedRow.id, + data: updatedRow.data, + createdAt: updatedRow.createdAt.toISOString(), + updatedAt: updatedRow.updatedAt.toISOString(), + }, + message: 'Row updated successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error updating row:`, error) + return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) + } +} + +/** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */ +export async function DELETE(request: NextRequest, { params }: RowRouteParams) { + const requestId = generateRequestId() + const { tableId, rowId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const validated = DeleteRowSchema.parse(body) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const [deletedRow] = await db + .delete(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId) + ) + ) + .returning() + + if (!deletedRow) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + + logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + message: 'Row deleted successfully', + deletedCount: 1, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error deleting row:`, error) + return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts new file mode 100644 index 0000000000..eecc0c3426 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -0,0 +1,663 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import { + checkUniqueConstraintsDb, + getUniqueColumns, + TABLE_LIMITS, + USER_TABLE_ROWS_SQL_NAME, + validateBatchRows, + validateRowAgainstSchema, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { buildFilterClause, buildSortClause } from '@/lib/table/sql' +import { accessError, checkAccess } from '../../utils' + +const logger = createLogger('TableRowsAPI') + +const InsertRowSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + data: z.record(z.unknown(), { required_error: 'Row data is required' }), +}) + +const BatchInsertRowsSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + rows: z + .array(z.record(z.unknown()), { required_error: 'Rows array is required' }) + .min(1, 'At least one row is required') + .max(1000, 'Cannot insert more than 1000 rows per batch'), +}) + +const QueryRowsSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + filter: z.record(z.unknown()).optional(), + sort: z.record(z.enum(['asc', 'desc'])).optional(), + limit: z.coerce + .number({ required_error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`) + .optional() + .default(100), + offset: z.coerce + .number({ required_error: 'Offset must be a number' }) + .int('Offset must be an integer') + .min(0, 'Offset must be 0 or greater') + .optional() + .default(0), +}) + +const UpdateRowsByFilterSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }), + data: z.record(z.unknown(), { required_error: 'Update data is required' }), + limit: z.coerce + .number({ required_error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max(1000, 'Cannot update more than 1000 rows per operation') + .optional(), +}) + +const DeleteRowsByFilterSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }), + limit: z.coerce + .number({ required_error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max(1000, 'Cannot delete more than 1000 rows per operation') + .optional(), +}) + +interface TableRowsRouteParams { + params: Promise<{ tableId: string }> +} + +async function handleBatchInsert( + requestId: string, + tableId: string, + body: z.infer, + userId: string +): Promise { + const validated = BatchInsertRowsSchema.parse(body) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const workspaceId = validated.workspaceId + + const remainingCapacity = table.maxRows - table.rowCount + if (remainingCapacity < validated.rows.length) { + return NextResponse.json( + { + error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`, + }, + { status: 400 } + ) + } + + const validation = await validateBatchRows({ + rows: validated.rows as RowData[], + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return validation.response + + const now = new Date() + const rowsToInsert = validated.rows.map((data) => ({ + id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + tableId, + workspaceId, + data, + createdAt: now, + updatedAt: now, + createdBy: userId, + })) + + const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning() + + logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + rows: insertedRows.map((r) => ({ + id: r.id, + data: r.data, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + insertedCount: insertedRows.length, + message: `Successfully inserted ${insertedRows.length} rows`, + }, + }) +} + +/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */ +export async function POST(request: NextRequest, { params }: TableRowsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + + if ( + typeof body === 'object' && + body !== null && + 'rows' in body && + Array.isArray((body as Record).rows) + ) { + return handleBatchInsert( + requestId, + tableId, + body as z.infer, + authResult.userId + ) + } + + const validated = InsertRowSchema.parse(body) + + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const workspaceId = validated.workspaceId + const rowData = validated.data as RowData + + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return validation.response + + if (table.rowCount >= table.maxRows) { + return NextResponse.json( + { error: `Table row limit reached (${table.maxRows} rows max)` }, + { status: 400 } + ) + } + + const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}` + const now = new Date() + + const [row] = await db + .insert(userTableRows) + .values({ + id: rowId, + tableId, + workspaceId, + data: validated.data, + createdAt: now, + updatedAt: now, + createdBy: authResult.userId, + }) + .returning() + + logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: row.data, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + message: 'Row inserted successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error inserting row:`, error) + return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) + } +} + +/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */ +export async function GET(request: NextRequest, { params }: TableRowsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspaceId') + const filterParam = searchParams.get('filter') + const sortParam = searchParams.get('sort') + const limit = searchParams.get('limit') + const offset = searchParams.get('offset') + + let filter: Record | undefined + let sort: Sort | undefined + + try { + if (filterParam) { + filter = JSON.parse(filterParam) as Record + } + if (sortParam) { + sort = JSON.parse(sortParam) as Sort + } + } catch { + return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) + } + + const validated = QueryRowsSchema.parse({ + workspaceId, + filter, + sort, + limit, + offset, + }) + + const accessResult = await checkAccess(tableId, authResult.userId, 'read') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] + + if (validated.filter) { + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) + } + } + + let query = db + .select({ + id: userTableRows.id, + data: userTableRows.data, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.sort) { + const schema = table.schema as TableSchema + const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) + if (sortClause) { + query = query.orderBy(sortClause) as typeof query + } + } else { + query = query.orderBy(userTableRows.createdAt) as typeof query + } + + const countQuery = db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) + + const [{ count: totalCount }] = await countQuery + + const rows = await query.limit(validated.limit).offset(validated.offset) + + logger.info( + `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` + ) + + return NextResponse.json({ + success: true, + data: { + rows: rows.map((r) => ({ + id: r.id, + data: r.data, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + rowCount: rows.length, + totalCount: Number(totalCount), + limit: validated.limit, + offset: validated.offset, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error querying rows:`, error) + return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + } +} + +/** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */ +export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const validated = UpdateRowsByFilterSchema.parse(body) + + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const updateData = validated.data as RowData + + const sizeValidation = validateRowSize(updateData) + if (!sizeValidation.valid) { + return NextResponse.json( + { error: 'Invalid row data', details: sizeValidation.errors }, + { status: 400 } + ) + } + + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] + + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) + } + + let matchingRowsQuery = db + .select({ + id: userTableRows.id, + data: userTableRows.data, + }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.limit) { + matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery + } + + const matchingRows = await matchingRowsQuery + + if (matchingRows.length === 0) { + return NextResponse.json( + { + success: true, + data: { + message: 'No rows matched the filter criteria', + updatedCount: 0, + }, + }, + { status: 200 } + ) + } + + if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`) + } + + for (const row of matchingRows) { + const existingData = row.data as RowData + const mergedData = { ...existingData, ...updateData } + const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema) + if (!rowValidation.valid) { + return NextResponse.json( + { + error: 'Updated data does not match schema', + details: rowValidation.errors, + affectedRowId: row.id, + }, + { status: 400 } + ) + } + } + + // Check unique constraints using optimized database query + const uniqueColumns = getUniqueColumns(table.schema as TableSchema) + if (uniqueColumns.length > 0) { + for (const row of matchingRows) { + const existingData = row.data as RowData + const mergedData = { ...existingData, ...updateData } + const uniqueValidation = await checkUniqueConstraintsDb( + tableId, + mergedData, + table.schema as TableSchema, + row.id + ) + + if (!uniqueValidation.valid) { + return NextResponse.json( + { + error: 'Unique constraint violation', + details: uniqueValidation.errors, + affectedRowId: row.id, + }, + { status: 400 } + ) + } + } + } + + const now = new Date() + + await db.transaction(async (trx) => { + let totalUpdated = 0 + + for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { + const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) + const updatePromises = batch.map((row) => { + const existingData = row.data as RowData + return trx + .update(userTableRows) + .set({ + data: { ...existingData, ...updateData }, + updatedAt: now, + }) + .where(eq(userTableRows.id, row.id)) + }) + await Promise.all(updatePromises) + totalUpdated += batch.length + logger.info( + `[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)` + ) + } + }) + + logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: matchingRows.length, + updatedRowIds: matchingRows.map((r) => r.id), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error updating rows by filter:`, error) + + const errorMessage = error instanceof Error ? error.message : String(error) + const detailedError = `Failed to update rows: ${errorMessage}` + + return NextResponse.json({ error: detailedError }, { status: 500 }) + } +} + +/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */ +export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const validated = DeleteRowsByFilterSchema.parse(body) + + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] + + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) + } + + let matchingRowsQuery = db + .select({ id: userTableRows.id }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.limit) { + matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery + } + + const matchingRows = await matchingRowsQuery + + if (matchingRows.length === 0) { + return NextResponse.json( + { + success: true, + data: { + message: 'No rows matched the filter criteria', + deletedCount: 0, + }, + }, + { status: 200 } + ) + } + + if (matchingRows.length > TABLE_LIMITS.DELETE_BATCH_SIZE) { + logger.warn(`[${requestId}] Deleting ${matchingRows.length} rows. This may take some time.`) + } + + const rowIds = matchingRows.map((r) => r.id) + + await db.transaction(async (trx) => { + let totalDeleted = 0 + + for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { + const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) + await trx.delete(userTableRows).where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + sql`${userTableRows.id} = ANY(ARRAY[${sql.join( + batch.map((id) => sql`${id}`), + sql`, ` + )}])` + ) + ) + totalDeleted += batch.length + logger.info( + `[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)` + ) + } + }) + + logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${tableId}`) + + return NextResponse.json({ + success: true, + data: { + message: 'Rows deleted successfully', + deletedCount: matchingRows.length, + deletedRowIds: rowIds, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error deleting rows by filter:`, error) + + const errorMessage = error instanceof Error ? error.message : String(error) + const detailedError = `Failed to delete rows: ${errorMessage}` + + return NextResponse.json({ error: detailedError }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts new file mode 100644 index 0000000000..fa4498190a --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -0,0 +1,175 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import type { RowData, TableSchema } from '@/lib/table' +import { getUniqueColumns, validateRowData } from '@/lib/table' +import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils' + +const logger = createLogger('TableUpsertAPI') + +const UpsertRowSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + data: z.record(z.unknown(), { required_error: 'Row data is required' }), +}) + +interface UpsertRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */ +export async function POST(request: NextRequest, { params }: UpsertRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const validated = UpsertRowSchema.parse(body) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId) + if (!isValidWorkspace) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const schema = table.schema as TableSchema + const rowData = validated.data as RowData + + const validation = await validateRowData({ + rowData, + schema, + tableId, + checkUnique: false, + }) + if (!validation.valid) return validation.response + + const uniqueColumns = getUniqueColumns(schema) + + if (uniqueColumns.length === 0) { + return NextResponse.json( + { + error: + 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.', + }, + { status: 400 } + ) + } + + const uniqueFilters = uniqueColumns.map((col) => { + const value = rowData[col.name] + if (value === undefined || value === null) { + return null + } + return sql`${userTableRows.data}->>${col.name} = ${String(value)}` + }) + + const validUniqueFilters = uniqueFilters.filter((f): f is Exclude => f !== null) + + if (validUniqueFilters.length === 0) { + return NextResponse.json( + { + error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`, + }, + { status: 400 } + ) + } + + const [existingRow] = await db + .select() + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ...validUniqueFilters + ) + ) + .limit(1) + + const now = new Date() + + const upsertResult = await db.transaction(async (trx) => { + if (existingRow) { + const [updatedRow] = await trx + .update(userTableRows) + .set({ + data: validated.data, + updatedAt: now, + }) + .where(eq(userTableRows.id, existingRow.id)) + .returning() + + return { + row: updatedRow, + operation: 'update' as const, + } + } + + const [insertedRow] = await trx + .insert(userTableRows) + .values({ + id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + tableId, + workspaceId: validated.workspaceId, + data: validated.data, + createdAt: now, + updatedAt: now, + createdBy: authResult.userId, + }) + .returning() + + return { + row: insertedRow, + operation: 'insert' as const, + } + }) + + logger.info( + `[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}` + ) + + return NextResponse.json({ + success: true, + data: { + row: { + id: upsertResult.row.id, + data: upsertResult.row.data, + createdAt: upsertResult.row.createdAt.toISOString(), + updatedAt: upsertResult.row.updatedAt.toISOString(), + }, + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error upserting row:`, error) + + const errorMessage = error instanceof Error ? error.message : String(error) + const detailedError = `Failed to upsert row: ${errorMessage}` + + return NextResponse.json({ error: detailedError }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts new file mode 100644 index 0000000000..0986d3bce9 --- /dev/null +++ b/apps/sim/app/api/table/route.ts @@ -0,0 +1,293 @@ +import { db } from '@sim/db' +import { permissions, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { + canCreateTable, + createTable, + getWorkspaceTableLimits, + listTables, + TABLE_LIMITS, + type TableSchema, +} from '@/lib/table' +import { normalizeColumn } from './utils' + +const logger = createLogger('TableAPI') + +const ColumnSchema = z.object({ + name: z + .string() + .min(1, 'Column name is required') + .max( + TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, + `Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + ) + .regex( + /^[a-z_][a-z0-9_]*$/i, + 'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores' + ), + type: z.enum(['string', 'number', 'boolean', 'date', 'json'], { + errorMap: () => ({ + message: 'Column type must be one of: string, number, boolean, date, json', + }), + }), + required: z.boolean().optional().default(false), + unique: z.boolean().optional().default(false), +}) + +const CreateTableSchema = z.object({ + name: z + .string() + .min(1, 'Table name is required') + .max( + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH, + `Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less` + ) + .regex( + /^[a-z_][a-z0-9_]*$/i, + 'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores' + ), + description: z + .string() + .max( + TABLE_LIMITS.MAX_DESCRIPTION_LENGTH, + `Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less` + ) + .optional(), + schema: z.object({ + columns: z + .array(ColumnSchema) + .min(1, 'Table must have at least one column') + .max( + TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` + ), + }), + workspaceId: z.string().min(1, 'Workspace ID is required'), +}) + +const ListTablesSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), +}) + +interface WorkspaceAccessResult { + hasAccess: boolean + canWrite: boolean +} + +async function checkWorkspaceAccess( + workspaceId: string, + userId: string +): Promise { + const [workspaceData] = await db + .select({ + id: workspace.id, + ownerId: workspace.ownerId, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return { hasAccess: false, canWrite: false } + } + + if (workspaceData.ownerId === userId) { + return { hasAccess: true, canWrite: true } + } + + const [permission] = await db + .select({ + permissionType: permissions.permissionType, + }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!permission) { + return { hasAccess: false, canWrite: false } + } + + const canWrite = permission.permissionType === 'admin' || permission.permissionType === 'write' + + return { + hasAccess: true, + canWrite, + } +} + +/** POST /api/table - Creates a new user-defined table. */ +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body: unknown = await request.json() + const params = CreateTableSchema.parse(body) + + const { hasAccess, canWrite } = await checkWorkspaceAccess( + params.workspaceId, + authResult.userId + ) + + if (!hasAccess || !canWrite) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + // Check billing plan limits + const existingTables = await listTables(params.workspaceId) + const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length) + + if (!canCreate) { + return NextResponse.json( + { + error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`, + }, + { status: 403 } + ) + } + + // Get plan-based row limits + const planLimits = await getWorkspaceTableLimits(params.workspaceId) + const maxRowsPerTable = planLimits.maxRowsPerTable + + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), + } + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId: authResult.userId, + maxRows: maxRowsPerTable, + }, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: table.schema, + rowCount: table.rowCount, + maxRows: table.maxRows, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), + }, + message: 'Table created successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + if (error instanceof Error) { + if ( + error.message.includes('Invalid table name') || + error.message.includes('Invalid schema') || + error.message.includes('already exists') || + error.message.includes('maximum table limit') + ) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + } + + logger.error(`[${requestId}] Error creating table:`, error) + return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) + } +} + +/** GET /api/table - Lists all tables in a workspace. */ +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspaceId') + + const validation = ListTablesSchema.safeParse({ workspaceId }) + if (!validation.success) { + return NextResponse.json( + { error: 'Validation error', details: validation.error.errors }, + { status: 400 } + ) + } + + const params = validation.data + + const { hasAccess } = await checkWorkspaceAccess(params.workspaceId, authResult.userId) + + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const tables = await listTables(params.workspaceId) + + logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + + return NextResponse.json({ + success: true, + data: { + tables: tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + ...t, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + createdAt: + t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: + t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + } + }), + totalCount: tables.length, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error listing tables:`, error) + return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts new file mode 100644 index 0000000000..5f8d01073b --- /dev/null +++ b/apps/sim/app/api/table/utils.ts @@ -0,0 +1,188 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { ColumnDefinition, TableDefinition } from '@/lib/table' +import { getTableById } from '@/lib/table' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('TableUtils') + +export interface TableAccessResult { + hasAccess: true + table: TableDefinition +} + +export interface TableAccessDenied { + hasAccess: false + notFound?: boolean + reason?: string +} + +export type TableAccessCheck = TableAccessResult | TableAccessDenied + +export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 } + +export interface ApiErrorResponse { + error: string + details?: unknown +} + +/** + * Check if a user has read access to a table. + * Read access is granted if: + * 1. User created the table, OR + * 2. User has any permission on the table's workspace (read, write, or admin) + * + * Follows the same pattern as Knowledge Base access checks. + */ +export async function checkTableAccess(tableId: string, userId: string): Promise { + const table = await getTableById(tableId) + + if (!table) { + return { hasAccess: false, notFound: true } + } + + // Case 1: User created the table + if (table.createdBy === userId) { + return { hasAccess: true, table } + } + + // Case 2: Table belongs to a workspace the user has permissions for + const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + if (userPermission !== null) { + return { hasAccess: true, table } + } + + return { hasAccess: false, reason: 'User does not have access to this table' } +} + +/** + * Check if a user has write access to a table. + * Write access is granted if: + * 1. User created the table, OR + * 2. User has write or admin permissions on the table's workspace + * + * Follows the same pattern as Knowledge Base write access checks. + */ +export async function checkTableWriteAccess( + tableId: string, + userId: string +): Promise { + const table = await getTableById(tableId) + + if (!table) { + return { hasAccess: false, notFound: true } + } + + // Case 1: User created the table + if (table.createdBy === userId) { + return { hasAccess: true, table } + } + + // Case 2: Table belongs to a workspace and user has write/admin permissions + const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + if (userPermission === 'write' || userPermission === 'admin') { + return { hasAccess: true, table } + } + + return { hasAccess: false, reason: 'User does not have write access to this table' } +} + +/** + * @deprecated Use checkTableAccess or checkTableWriteAccess instead. + * Legacy access check function for backwards compatibility. + */ +export async function checkAccess( + tableId: string, + userId: string, + level: 'read' | 'write' | 'admin' = 'read' +): Promise { + const table = await getTableById(tableId) + + if (!table) { + return { ok: false, status: 404 } + } + + if (table.createdBy === userId) { + return { ok: true, table } + } + + const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + const hasAccess = + permission !== null && + (level === 'read' || + (level === 'write' && (permission === 'write' || permission === 'admin')) || + (level === 'admin' && permission === 'admin')) + + return hasAccess ? { ok: true, table } : { ok: false, status: 403 } +} + +export function accessError( + result: { ok: false; status: 404 | 403 }, + requestId: string, + context?: string +): NextResponse { + const message = result.status === 404 ? 'Table not found' : 'Access denied' + logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) + return NextResponse.json({ error: message }, { status: result.status }) +} + +/** + * Converts a TableAccessDenied result to an appropriate HTTP response. + * Use with checkTableAccess or checkTableWriteAccess. + */ +export function tableAccessError( + result: TableAccessDenied, + requestId: string, + context?: string +): NextResponse { + const status = result.notFound ? 404 : 403 + const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied') + logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) + return NextResponse.json({ error: message }, { status }) +} + +export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise { + const table = await getTableById(tableId) + return table?.workspaceId === workspaceId +} + +export function errorResponse( + message: string, + status: number, + details?: unknown +): NextResponse { + const body: ApiErrorResponse = { error: message } + if (details !== undefined) { + body.details = details + } + return NextResponse.json(body, { status }) +} + +export function badRequestResponse(message: string, details?: unknown) { + return errorResponse(message, 400, details) +} + +export function unauthorizedResponse(message = 'Authentication required') { + return errorResponse(message, 401) +} + +export function forbiddenResponse(message = 'Access denied') { + return errorResponse(message, 403) +} + +export function notFoundResponse(message = 'Resource not found') { + return errorResponse(message, 404) +} + +export function serverErrorResponse(message = 'Internal server error') { + return errorResponse(message, 500) +} + +export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { + return { + name: col.name, + type: col.type, + required: col.required ?? false, + unique: col.unique ?? false, + } +} diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 54f914a2b7..c21f80e58b 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -11,6 +11,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' +import { enrichTableSchema } from '@/lib/table/llm/wand' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { getModelPricing } from '@/providers/utils' @@ -60,6 +61,7 @@ interface RequestBody { history?: ChatMessage[] workflowId?: string generationType?: string + wandContext?: Record } function safeStringify(value: unknown): string { @@ -70,6 +72,38 @@ function safeStringify(value: unknown): string { } } +/** + * Wand enricher function type. + * Enrichers add context to the system prompt based on generationType. + */ +type WandEnricher = ( + workspaceId: string | null, + context: Record +) => Promise + +/** + * Registry of wand enrichers by generationType. + * Each enricher returns additional context to append to the system prompt. + */ +const wandEnrichers: Partial> = { + timestamp: async () => { + const now = new Date() + return `Current date and time context for reference: +- Current UTC timestamp: ${now.toISOString()} +- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)} +- Current Unix timestamp (milliseconds): ${now.getTime()} +- Current date (UTC): ${now.toISOString().split('T')[0]} +- Current year: ${now.getUTCFullYear()} +- Current month: ${now.getUTCMonth() + 1} +- Current day of month: ${now.getUTCDate()} +- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]} + +Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.` + }, + + 'table-schema': enrichTableSchema, +} + async function updateUserStatsForWand( userId: string, usage: { @@ -159,7 +193,15 @@ export async function POST(req: NextRequest) { try { const body = (await req.json()) as RequestBody - const { prompt, systemPrompt, stream = false, history = [], workflowId, generationType } = body + const { + prompt, + systemPrompt, + stream = false, + history = [], + workflowId, + generationType, + wandContext = {}, + } = body if (!prompt) { logger.warn(`[${requestId}] Invalid request: Missing prompt.`) @@ -227,20 +269,15 @@ export async function POST(req: NextRequest) { systemPrompt || 'You are a helpful AI assistant. Generate content exactly as requested by the user.' - if (generationType === 'timestamp') { - const now = new Date() - const currentTimeContext = `\n\nCurrent date and time context for reference: -- Current UTC timestamp: ${now.toISOString()} -- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)} -- Current Unix timestamp (milliseconds): ${now.getTime()} -- Current date (UTC): ${now.toISOString().split('T')[0]} -- Current year: ${now.getUTCFullYear()} -- Current month: ${now.getUTCMonth() + 1} -- Current day of month: ${now.getUTCDate()} -- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]} - -Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.` - finalSystemPrompt += currentTimeContext + // Apply enricher if one exists for this generationType + if (generationType) { + const enricher = wandEnrichers[generationType] + if (enricher) { + const enrichment = await enricher(workspaceId, wandContext) + if (enrichment) { + finalSystemPrompt += `\n\n${enrichment}` + } + } } if (generationType === 'json-object') { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/action-bar.tsx new file mode 100644 index 0000000000..c5472658aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/action-bar.tsx @@ -0,0 +1,31 @@ +'use client' + +import { Trash2, X } from 'lucide-react' +import { Button } from '@/components/emcn' + +interface ActionBarProps { + selectedCount: number + onDelete: () => void + onClearSelection: () => void +} + +export function ActionBar({ selectedCount, onDelete, onClearSelection }: ActionBarProps) { + return ( +
+
+ + {selectedCount} {selectedCount === 1 ? 'row' : 'rows'} selected + + +
+ + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/body-states.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/body-states.tsx new file mode 100644 index 0000000000..3f391ac1e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/body-states.tsx @@ -0,0 +1,72 @@ +import { Plus } from 'lucide-react' +import { Button, TableCell, TableRow } from '@/components/emcn' +import { Skeleton } from '@/components/ui/skeleton' +import type { ColumnDefinition } from '@/lib/table' + +interface LoadingRowsProps { + columns: ColumnDefinition[] +} + +export function LoadingRows({ columns }: LoadingRowsProps) { + return ( + <> + {Array.from({ length: 25 }).map((_, rowIndex) => ( + + + + + {columns.map((col, colIndex) => { + const baseWidth = + col.type === 'json' + ? 200 + : col.type === 'string' + ? 160 + : col.type === 'number' + ? 80 + : col.type === 'boolean' + ? 50 + : col.type === 'date' + ? 100 + : 120 + const variation = ((rowIndex + colIndex) % 3) * 20 + const width = baseWidth + variation + + return ( + + + + ) + })} + + ))} + + ) +} + +interface EmptyRowsProps { + columnCount: number + hasFilter: boolean + onAddRow: () => void +} + +export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) { + return ( + + +
+
+ + {hasFilter ? 'No rows match your filter' : 'No data'} + + {!hasFilter && ( + + )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-renderer.tsx new file mode 100644 index 0000000000..f4e97e555e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-renderer.tsx @@ -0,0 +1,99 @@ +import type { ColumnDefinition } from '@/lib/table' +import { STRING_TRUNCATE_LENGTH } from '../lib/constants' +import type { CellViewerData } from '../lib/types' + +interface CellRendererProps { + value: unknown + column: ColumnDefinition + onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void +} + +export function CellRenderer({ value, column, onCellClick }: CellRendererProps) { + const isNull = value === null || value === undefined + + if (isNull) { + return — + } + + if (column.type === 'json') { + const jsonStr = JSON.stringify(value) + return ( + + ) + } + + if (column.type === 'boolean') { + const boolValue = Boolean(value) + return ( + + {boolValue ? 'true' : 'false'} + + ) + } + + if (column.type === 'number') { + return ( + {String(value)} + ) + } + + if (column.type === 'date') { + try { + const date = new Date(String(value)) + const formatted = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + return ( + + ) + } catch { + return {String(value)} + } + } + + const strValue = String(value) + if (strValue.length > STRING_TRUNCATE_LENGTH) { + return ( + + ) + } + + return {strValue} +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-viewer-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-viewer-modal.tsx new file mode 100644 index 0000000000..8139d22732 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/cell-viewer-modal.tsx @@ -0,0 +1,84 @@ +import { Copy, X } from 'lucide-react' +import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn' +import type { CellViewerData } from '../lib/types' + +interface CellViewerModalProps { + cellViewer: CellViewerData | null + onClose: () => void + onCopy: () => void + copied: boolean +} + +export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) { + if (!cellViewer) return null + + return ( + !open && onClose()}> + +
+
+ + {cellViewer.columnName} + + + {cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'} + +
+
+ + +
+
+ + {cellViewer.type === 'json' ? ( +
+              {JSON.stringify(cellViewer.value, null, 2)}
+            
+ ) : cellViewer.type === 'date' ? ( +
+
+
+ Formatted +
+
+ {new Date(String(cellViewer.value)).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + })} +
+
+
+
+ ISO Format +
+
+ {String(cellViewer.value)} +
+
+
+ ) : ( +
+ {String(cellViewer.value)} +
+ )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu.tsx new file mode 100644 index 0000000000..eb1dcb8989 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu.tsx @@ -0,0 +1,49 @@ +import { Edit, Trash2 } from 'lucide-react' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' +import type { ContextMenuState } from '../lib/types' + +interface ContextMenuProps { + contextMenu: ContextMenuState + onClose: () => void + onEdit: () => void + onDelete: () => void +} + +export function ContextMenu({ contextMenu, onClose, onEdit, onDelete }: ContextMenuProps) { + return ( + !open && onClose()} + variant='secondary' + size='sm' + colorScheme='inverted' + > + + + + + Edit row + + + + + Delete row + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/header-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/header-bar.tsx new file mode 100644 index 0000000000..eb589cb0b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/header-bar.tsx @@ -0,0 +1,63 @@ +import { Info, RefreshCw } from 'lucide-react' +import { Badge, Button, Tooltip } from '@/components/emcn' +import { Skeleton } from '@/components/ui/skeleton' + +interface HeaderBarProps { + tableName: string + totalCount: number + isLoading: boolean + onNavigateBack: () => void + onShowSchema: () => void + onRefresh: () => void +} + +export function HeaderBar({ + tableName, + totalCount, + isLoading, + onNavigateBack, + onShowSchema, + onRefresh, +}: HeaderBarProps) { + return ( +
+
+ + / + {tableName} + {isLoading ? ( + + ) : ( + + {totalCount} {totalCount === 1 ? 'row' : 'rows'} + + )} +
+ +
+ + + + + View Schema + + + + + + + Refresh + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts new file mode 100644 index 0000000000..e4594de055 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -0,0 +1,11 @@ +export * from './action-bar' +export * from './body-states' +export * from './cell-renderer' +export * from './cell-viewer-modal' +export * from './context-menu' +export * from './header-bar' +export * from './pagination' +export * from './query-builder' +export * from './row-modal' +export * from './schema-modal' +export * from './table-viewer' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/pagination.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/pagination.tsx new file mode 100644 index 0000000000..e73256a63f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/pagination.tsx @@ -0,0 +1,40 @@ +import { Button } from '@/components/emcn' + +interface PaginationProps { + currentPage: number + totalPages: number + totalCount: number + onPreviousPage: () => void + onNextPage: () => void +} + +export function Pagination({ + currentPage, + totalPages, + totalCount, + onPreviousPage, + onNextPage, +}: PaginationProps) { + if (totalPages <= 1) return null + + return ( +
+ + Page {currentPage + 1} of {totalPages} ({totalCount} rows) + +
+ + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/filter-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/filter-row.tsx new file mode 100644 index 0000000000..a54beec61b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/filter-row.tsx @@ -0,0 +1,89 @@ +'use client' + +import { X } from 'lucide-react' +import { Button, Combobox, Input } from '@/components/emcn' +import type { FilterRule } from '@/lib/table/query-builder/constants' + +interface FilterRowProps { + rule: FilterRule + index: number + columnOptions: Array<{ value: string; label: string }> + comparisonOptions: Array<{ value: string; label: string }> + logicalOptions: Array<{ value: string; label: string }> + onUpdate: (id: string, field: keyof FilterRule, value: string) => void + onRemove: (id: string) => void + onApply: () => void +} + +export function FilterRow({ + rule, + index, + columnOptions, + comparisonOptions, + logicalOptions, + onUpdate, + onRemove, + onApply, +}: FilterRowProps) { + return ( +
+ + +
+ {index === 0 ? ( + + ) : ( + onUpdate(rule.id, 'logicalOperator', value as 'and' | 'or')} + /> + )} +
+ +
+ onUpdate(rule.id, 'column', value)} + placeholder='Column' + /> +
+ +
+ onUpdate(rule.id, 'operator', value)} + /> +
+ + onUpdate(rule.id, 'value', e.target.value)} + placeholder='Value' + onKeyDown={(e) => { + if (e.key === 'Enter') { + onApply() + } + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/index.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/index.tsx new file mode 100644 index 0000000000..379a769fcf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/index.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { ArrowUpAZ, Loader2, Plus } from 'lucide-react' +import { nanoid } from 'nanoid' +import { Button } from '@/components/emcn' +import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' +import { filterRulesToFilter, sortRuleToSort } from '@/lib/table/query-builder/converters' +import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder' +import type { ColumnDefinition } from '@/lib/table/types' +import type { QueryOptions } from '../../lib/types' +import { FilterRow } from './filter-row' +import { SortRow } from './sort-row' + +type Column = Pick + +interface QueryBuilderProps { + columns: Column[] + onApply: (options: QueryOptions) => void + onAddRow: () => void + isLoading?: boolean +} + +export function QueryBuilder({ columns, onApply, onAddRow, isLoading = false }: QueryBuilderProps) { + const [rules, setRules] = useState([]) + const [sortRule, setSortRule] = useState(null) + + const columnOptions = useMemo( + () => columns.map((col) => ({ value: col.name, label: col.name })), + [columns] + ) + + const { + comparisonOptions, + logicalOptions, + sortDirectionOptions, + addRule: handleAddRule, + removeRule: handleRemoveRule, + updateRule: handleUpdateRule, + } = useFilterBuilder({ + columns: columnOptions, + rules, + setRules, + }) + + const handleAddSort = useCallback(() => { + setSortRule({ + id: nanoid(), + column: columns[0]?.name || '', + direction: 'asc', + }) + }, [columns]) + + const handleRemoveSort = useCallback(() => { + setSortRule(null) + }, []) + + const handleApply = useCallback(() => { + const filter = filterRulesToFilter(rules) + const sort = sortRuleToSort(sortRule) + onApply({ filter, sort }) + }, [rules, sortRule, onApply]) + + const handleClear = useCallback(() => { + setRules([]) + setSortRule(null) + onApply({ + filter: null, + sort: null, + }) + }, [onApply]) + + const hasChanges = rules.length > 0 || sortRule !== null + + return ( +
+ {rules.map((rule, index) => ( + + ))} + + {sortRule && ( + + )} + +
+ + + + + {!sortRule && ( + + )} + + {hasChanges && ( + <> + + + + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/sort-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/sort-row.tsx new file mode 100644 index 0000000000..5e0641be74 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/query-builder/sort-row.tsx @@ -0,0 +1,65 @@ +'use client' + +import { ArrowDownAZ, ArrowUpAZ, X } from 'lucide-react' +import { Button, Combobox } from '@/components/emcn' +import type { SortRule } from '@/lib/table/query-builder/constants' + +interface SortRowProps { + sortRule: SortRule + columnOptions: Array<{ value: string; label: string }> + sortDirectionOptions: Array<{ value: string; label: string }> + onChange: (rule: SortRule | null) => void + onRemove: () => void +} + +export function SortRow({ + sortRule, + columnOptions, + sortDirectionOptions, + onChange, + onRemove, +}: SortRowProps) { + return ( +
+ + +
+ +
+ +
+ onChange({ ...sortRule, column: value })} + placeholder='Column' + /> +
+ +
+ onChange({ ...sortRule, direction: value as 'asc' | 'desc' })} + /> +
+ +
+ {sortRule.direction === 'asc' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx new file mode 100644 index 0000000000..e321cf97b5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx @@ -0,0 +1,405 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import { AlertCircle } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + Button, + Checkbox, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Textarea, +} from '@/components/emcn' +import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' + +const logger = createLogger('RowModal') + +export interface RowModalProps { + mode: 'add' | 'edit' | 'delete' + isOpen: boolean + onClose: () => void + table: TableInfo + row?: TableRow + rowIds?: string[] + onSuccess: () => void +} + +function createInitialRowData(columns: ColumnDefinition[]): Record { + const initial: Record = {} + columns.forEach((col) => { + if (col.type === 'boolean') { + initial[col.name] = false + } else { + initial[col.name] = '' + } + }) + return initial +} + +function cleanRowData( + columns: ColumnDefinition[], + rowData: Record +): Record { + const cleanData: Record = {} + + columns.forEach((col) => { + const value = rowData[col.name] + if (col.type === 'number') { + cleanData[col.name] = value === '' ? null : Number(value) + } else if (col.type === 'json') { + if (typeof value === 'string') { + if (value === '') { + cleanData[col.name] = null + } else { + try { + cleanData[col.name] = JSON.parse(value) + } catch { + throw new Error(`Invalid JSON for field: ${col.name}`) + } + } + } else { + cleanData[col.name] = value + } + } else if (col.type === 'boolean') { + cleanData[col.name] = Boolean(value) + } else { + cleanData[col.name] = value || null + } + }) + + return cleanData +} + +function formatValueForInput(value: unknown, type: string): string { + if (value === null || value === undefined) return '' + if (type === 'json') { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) + } + if (type === 'date' && value) { + try { + const date = new Date(String(value)) + return date.toISOString().split('T')[0] + } catch { + return String(value) + } + } + return String(value) +} + +export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + const schema = table?.schema + const columns = schema?.columns || [] + + const [rowData, setRowData] = useState>({}) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Initialize form data based on mode + useEffect(() => { + if (!isOpen) return + + if (mode === 'add' && columns.length > 0) { + setRowData(createInitialRowData(columns)) + } else if (mode === 'edit' && row) { + setRowData(row.data) + } + }, [isOpen, mode, columns, row]) + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setIsSubmitting(true) + + try { + const cleanData = cleanRowData(columns, rowData) + + if (mode === 'add') { + const res = await fetch(`/api/table/${table?.id}/rows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId, data: cleanData }), + }) + + const result: { error?: string } = await res.json() + if (!res.ok) { + throw new Error(result.error || 'Failed to add row') + } + } else if (mode === 'edit' && row) { + const res = await fetch(`/api/table/${table?.id}/rows/${row.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId, data: cleanData }), + }) + + const result: { error?: string } = await res.json() + if (!res.ok) { + throw new Error(result.error || 'Failed to update row') + } + } + + onSuccess() + } catch (err) { + logger.error(`Failed to ${mode} row:`, err) + setError(err instanceof Error ? err.message : `Failed to ${mode} row`) + } finally { + setIsSubmitting(false) + } + } + + const handleDelete = async () => { + setError(null) + setIsSubmitting(true) + + const idsToDelete = rowIds ?? (row ? [row.id] : []) + + try { + if (idsToDelete.length === 1) { + const res = await fetch(`/api/table/${table?.id}/rows/${idsToDelete[0]}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }) + + if (!res.ok) { + const result: { error?: string } = await res.json() + throw new Error(result.error || 'Failed to delete row') + } + } else { + const results = await Promise.allSettled( + idsToDelete.map(async (rowId) => { + const res = await fetch(`/api/table/${table?.id}/rows/${rowId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }) + + if (!res.ok) { + const result: { error?: string } = await res.json().catch(() => ({})) + throw new Error(result.error || `Failed to delete row ${rowId}`) + } + + return rowId + }) + ) + + const failures = results.filter((r) => r.status === 'rejected') + + if (failures.length > 0) { + const failureCount = failures.length + const totalCount = idsToDelete.length + const successCount = totalCount - failureCount + const firstError = + failures[0].status === 'rejected' ? failures[0].reason?.message || 'Unknown error' : '' + + throw new Error( + `Failed to delete ${failureCount} of ${totalCount} row(s)${successCount > 0 ? ` (${successCount} deleted successfully)` : ''}. ${firstError}` + ) + } + } + + onSuccess() + } catch (err) { + logger.error('Failed to delete row(s):', err) + setError(err instanceof Error ? err.message : 'Failed to delete row(s)') + } finally { + setIsSubmitting(false) + } + } + + const handleClose = () => { + setRowData({}) + setError(null) + onClose() + } + + // Delete mode UI + if (mode === 'delete') { + const deleteCount = rowIds?.length ?? (row ? 1 : 0) + const isSingleRow = deleteCount === 1 + + return ( + + + +
+
+ +
+

+ Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`} +

+
+
+ +
+ +

+ Are you sure you want to delete {isSingleRow ? 'this row' : 'these rows'}? This + action cannot be undone. +

+
+
+ + + + +
+
+ ) + } + + const isAddMode = mode === 'add' + + return ( + + + +
+

{isAddMode ? 'Add New Row' : 'Edit Row'}

+

+ {isAddMode ? 'Fill in the values for' : 'Update values for'} {table?.name ?? 'table'} +

+
+
+ +
+ + + {columns.map((column) => ( + setRowData((prev) => ({ ...prev, [column.name]: value }))} + /> + ))} + +
+ + + + +
+
+ ) +} + +function ErrorMessage({ error }: { error: string | null }) { + if (!error) return null + + return ( +
+ {error} +
+ ) +} + +interface ColumnFieldProps { + column: ColumnDefinition + value: unknown + onChange: (value: unknown) => void +} + +function ColumnField({ column, value, onChange }: ColumnFieldProps) { + return ( +
+ + + {column.type === 'boolean' ? ( +
+ onChange(checked === true)} + /> + +
+ ) : column.type === 'json' ? ( +