Compare commits

..

3 Commits

Author SHA1 Message Date
Siddharth Ganesan
6fe8f2aa04 Temp 2026-01-21 10:24:58 -08:00
Siddharth Ganesan
a4007c7e7e v0 2026-01-20 18:22:59 -08:00
Siddharth Ganesan
71c92788c5 Add deploy subagent response 2026-01-20 17:53:35 -08:00
336 changed files with 5127 additions and 29284 deletions

View File

@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease

View File

@@ -4093,23 +4093,6 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='10 14 60 52'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<path
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
fill='currentColor'
/>
</svg>
)
}
export function McpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4696,26 +4679,6 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TableIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<rect width='18' height='18' x='3' y='3' rx='2' />
<path d='M3 9h18' />
<path d='M3 15h18' />
<path d='M9 3v18' />
<path d='M15 3v18' />
</svg>
)
}
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -108,10 +108,8 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TableIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
@@ -145,7 +143,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
calendly: CalendlyIcon,
circleback: CirclebackIcon,
clay: ClayIcon,
confluence_v2: ConfluenceIcon,
confluence: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
discord: DiscordIcon,
@@ -155,7 +153,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
exa: ExaAIIcon,
file_v2: DocumentIcon,
file: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies: FirefliesIcon,
github_v2: GithubIcon,
@@ -197,7 +195,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse_v2: MistralIcon,
mistral_parse: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
@@ -237,10 +235,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt: STTIcon,
supabase: SupabaseIcon,
table: TableIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract: TextractIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
@@ -248,7 +244,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
video_generator_v2: VideoIcon,
video_generator: VideoIcon,
vision: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,

View File

@@ -6,7 +6,7 @@ description: Interact with Confluence
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="confluence_v2"
type="confluence"
color="#E0E0E0"
/>

View File

@@ -6,7 +6,7 @@ description: Read and parse multiple files
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="file_v2"
type="file"
color="#40916C"
/>
@@ -48,7 +48,7 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | array | Array of parsed files with content, metadata, and file properties |
| `combinedContent` | string | All file contents merged into a single text string |
| `files` | array | Array of parsed files |
| `combinedContent` | string | Combined content of all parsed files |

View File

@@ -104,10 +104,8 @@
"stripe",
"stt",
"supabase",
"table",
"tavily",
"telegram",
"textract",
"tinybird",
"translate",
"trello",

View File

@@ -6,7 +6,7 @@ description: Extract text from PDF documents
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mistral_parse_v2"
type="mistral_parse"
color="#000000"
/>
@@ -54,37 +54,18 @@ Parse PDF documents using Mistral OCR API
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `pages` | array | Array of page objects from Mistral OCR |
| ↳ `index` | number | Page index \(zero-based\) |
| ↳ `markdown` | string | Extracted markdown content |
| ↳ `images` | array | Images extracted from this page with bounding boxes |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `dimensions` | object | Page dimensions |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ |
| ↳ `header` | string | Page header content \(when extract_header=true\) |
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
| `usage_info` | object | Usage and processing statistics |
| ↳ `pages_processed` | number | Total number of pages processed |
| ↳ `doc_size_bytes` | number | Document file size in bytes |
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
| `success` | boolean | Whether the PDF was parsed successfully |
| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) |
| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info |
| ↳ `jobId` | string | Unique job identifier |
| ↳ `fileType` | string | File type \(e.g., pdf\) |
| ↳ `fileName` | string | Original file name |
| ↳ `source` | string | Source type \(url\) |
| ↳ `pageCount` | number | Number of pages processed |
| ↳ `model` | string | Mistral model used |
| ↳ `resultType` | string | Output format \(markdown, text, json\) |
| ↳ `processedAt` | string | Processing timestamp |
| ↳ `sourceUrl` | string | Source URL if applicable |
| ↳ `usageInfo` | object | Usage statistics from OCR processing |

View File

@@ -58,7 +58,6 @@ Upload a file to an AWS S3 bucket
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | URL of the uploaded S3 object |
| `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) |
| `metadata` | object | Upload metadata including ETag and location |
### `s3_get_object`
@@ -150,7 +149,6 @@ Copy an object within or between AWS S3 buckets
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | URL of the copied S3 object |
| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) |
| `metadata` | object | Copy operation metadata |

View File

@@ -1,351 +0,0 @@
---
title: Table
description: User-defined data tables for storing and querying structured data
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="table"
color="#10B981"
/>
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

View File

@@ -1,120 +0,0 @@
---
title: AWS Textract
description: Extract text, tables, and forms from documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="textract"
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more.
With AWS Textract, you can:
- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF
- **Detect and extract tables**: Automatically find tables and output their structured content
- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values
- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements
- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?")
In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.
## Tools
### `textract_parser`
Parse documents using AWS Textract OCR and document analysis
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | AWS Access Key ID |
| `secretAccessKey` | string | Yes | AWS Secret Access Key |
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
| `fileUpload` | object | No | File upload data from file-upload component |
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
| `items` | string | No | Feature type |
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
| `items` | object | No | Query configuration |
| `properties` | string | No | The query text |
| `Text` | string | No | No description |
| `Alias` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
| ↳ `Id` | string | Unique identifier for the block |
| ↳ `Text` | string | Query text |
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
| ↳ `Confidence` | number | Confidence score \(0-100\) |
| ↳ `Page` | number | Page number |
| ↳ `Geometry` | object | Location and bounding box information |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `Relationships` | array | Relationships to other blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
| ↳ `RowIndex` | number | Row index for table cells |
| ↳ `ColumnIndex` | number | Column index for table cells |
| ↳ `RowSpan` | number | Row span for merged cells |
| ↳ `ColumnSpan` | number | Column span for merged cells |
| ↳ `Query` | object | Query information for QUERY blocks |
| ↳ `Text` | string | Query text |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| `documentMetadata` | object | Metadata about the analyzed document |
| ↳ `pages` | number | Number of pages in the document |
| `modelVersion` | string | Version of the Textract model used for processing |

View File

@@ -6,7 +6,7 @@ description: Generate videos from text using AI
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="video_generator_v2"
type="video_generator"
color="#181C1E"
/>

View File

@@ -24,7 +24,6 @@ import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('LoginForm')
@@ -106,7 +105,7 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
@@ -124,7 +123,6 @@ export default function LoginPage({
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
useEffect(() => {
setMounted(true)
@@ -141,12 +139,32 @@ export default function LoginPage({
const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow)
}
const resetSuccess = searchParams.get('resetSuccess') === 'true'
if (resetSuccess) {
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
useEffect(() => {
@@ -203,7 +221,6 @@ export default function LoginPage({
try {
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
let errorHandled = false
const result = await client.signIn.email(
{
@@ -214,16 +231,11 @@ export default function LoginPage({
{
onError: (ctx) => {
logger.error('Login error:', ctx.error)
// EMAIL_NOT_VERIFIED is handled by the catch block which redirects to /verify
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
return
}
errorHandled = true
const errorMessage: string[] = ['Invalid email or password']
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
return
}
if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled')
@@ -259,7 +271,6 @@ export default function LoginPage({
errorMessage.push('Too many requests. Please wait a moment before trying again.')
}
setResetSuccessMessage(null)
setPasswordErrors(errorMessage)
setShowValidationError(true)
},
@@ -267,22 +278,9 @@ export default function LoginPage({
)
if (!result || result.error) {
// Show error if not already handled by onError callback
if (!errorHandled) {
setResetSuccessMessage(null)
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
setPasswordErrors([errorMessage])
setShowValidationError(true)
}
setIsLoading(false)
return
}
// Clear reset success message on successful login
setResetSuccessMessage(null)
// Explicit redirect fallback if better-auth doesn't redirect
router.push(safeCallbackUrl)
} catch (err: any) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
if (typeof window !== 'undefined') {
@@ -402,13 +400,6 @@ export default function LoginPage({
</div>
)}
{/* Password reset success message */}
{resetSuccessMessage && (
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
<p>{resetSuccessMessage}</p>
</div>
)}
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>

View File

@@ -1,13 +1,12 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface RequestResetFormProps {
email: string
@@ -28,9 +27,36 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
@@ -112,9 +138,36 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

View File

@@ -16,7 +16,6 @@ import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SignupForm')
@@ -96,7 +95,7 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('')
@@ -127,6 +126,31 @@ function SignupFormContent({
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
const validatePassword = (passwordValue: string): string[] => {

View File

@@ -13,7 +13,6 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SSOForm')
@@ -58,7 +57,7 @@ export default function SSOForm() {
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => {
@@ -91,6 +90,31 @@ export default function SSOForm() {
setShowEmailValidationError(true)
}
}
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -8,7 +8,6 @@ import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useVerification } from '@/app/(auth)/verify/use-verification'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface VerifyContentProps {
hasEmailService: boolean
@@ -59,7 +58,34 @@ function VerificationForm({
setCountdown(30)
}
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
return (
<>

View File

@@ -15,8 +15,7 @@ const resetPasswordSchema = z.object({
.max(100, 'Password must not exceed 100 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
.regex(/[0-9]/, 'Password must contain at least one number'),
})
export async function POST(request: NextRequest) {

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { after } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -16,16 +17,35 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import {
type RenderEvent,
serializeRenderEvent,
} from '@/lib/copilot/render-events'
import {
appendChunk,
appendContent,
checkAbortSignal,
completeStream,
createStream,
errorStream,
refreshStreamTTL,
updateToolCall,
} from '@/lib/copilot/stream-persistence'
import { transformStream } from '@/lib/copilot/stream-transformer'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/core/config/env'
import { CopilotFiles } from '@/lib/uploads'
import { CopilotFiles } from '@/lib/uploads'
import { createFileContent } from '@/lib/uploads/utils/file-utils'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
const logger = createLogger('CopilotChatAPI')
export const dynamic = 'force-dynamic'
export const fetchCache = 'force-no-store'
export const runtime = 'nodejs'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const FileAttachmentSchema = z.object({
@@ -492,385 +512,250 @@ export async function POST(req: NextRequest) {
)
}
// If streaming is requested, forward the stream and update chat later
// If streaming is requested, return a DIRECT SSE stream for low latency
// Also persist to Redis in background for stream resumption
if (stream && simAgentResponse.body) {
// Create user message to save
const userMessage = {
id: userMessageIdToUse, // Consistent ID used for request and persistence
role: 'user',
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
...(Array.isArray(contexts) &&
contexts.length > 0 && {
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
}),
// Create stream ID for persistence and resumption
const streamId = crypto.randomUUID()
// Initialize stream state in Redis (fire-and-forget)
createStream({
streamId,
chatId: actualChatId!,
userId: authenticatedUserId,
workflowId,
userMessageId: userMessageIdToUse,
isClientSession: true,
}).catch(() => {})
// Save user message to database immediately so it's available on refresh
// This is critical for stream resumption - user message must be persisted before stream starts
if (currentChat) {
const existingMessages = Array.isArray(currentChat.messages) ? currentChat.messages : []
const userMessage = {
id: userMessageIdToUse,
role: 'user',
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
...(Array.isArray(contexts) &&
contexts.length > 0 && {
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
}),
}
// Fire-and-forget - don't block the stream
db.update(copilotChats)
.set({
messages: [...existingMessages, userMessage],
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
.catch(() => {})
logger.info(`[${tracker.requestId}] Saving user message (async)`, {
chatId: actualChatId,
messageId: userMessageIdToUse,
})
}
// Create a pass-through stream that captures the response
const transformedStream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
let assistantContent = ''
const toolCalls: any[] = []
let buffer = ''
const isFirstDone = true
let responseIdFromStart: string | undefined
let responseIdFromDone: string | undefined
// Track tool call progress to identify a safe done event
const announcedToolCallIds = new Set<string>()
const startedToolExecutionIds = new Set<string>()
const completedToolExecutionIds = new Set<string>()
let lastDoneResponseId: string | undefined
let lastSafeDoneResponseId: string | undefined
// Capture needed values
const capturedChatId = actualChatId!
const capturedCurrentChat = currentChat
const assistantMessageId = crypto.randomUUID()
// Send chatId as first event
if (actualChatId) {
const chatIdEvent = `data: ${JSON.stringify({
type: 'chat_id',
chatId: actualChatId,
})}\n\n`
controller.enqueue(encoder.encode(chatIdEvent))
logger.debug(`[${tracker.requestId}] Sent initial chatId event to client`)
}
// Start title generation in parallel if needed
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
generateChatTitle(message)
.then(async (title) => {
if (title) {
await db
.update(copilotChats)
.set({
title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
const titleEvent = `data: ${JSON.stringify({
type: 'title_updated',
title: title,
})}\n\n`
controller.enqueue(encoder.encode(titleEvent))
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
} else {
logger.debug(`[${tracker.requestId}] Skipping title generation`)
}
// Forward the sim agent stream and capture assistant response
const reader = simAgentResponse.body!.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
// Decode and parse SSE events for logging and capturing content
const decodedChunk = decoder.decode(value, { stream: true })
buffer += decodedChunk
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim() === '') continue // Skip empty lines
if (line.startsWith('data: ') && line.length > 6) {
try {
const jsonStr = line.slice(6)
// Check if the JSON string is unusually large (potential streaming issue)
if (jsonStr.length > 50000) {
// 50KB limit
logger.warn(`[${tracker.requestId}] Large SSE event detected`, {
size: jsonStr.length,
preview: `${jsonStr.substring(0, 100)}...`,
})
}
const event = JSON.parse(jsonStr)
// Log different event types comprehensively
switch (event.type) {
case 'content':
if (event.data) {
assistantContent += event.data
}
break
case 'reasoning':
logger.debug(
`[${tracker.requestId}] Reasoning chunk received (${(event.data || event.content || '').length} chars)`
)
break
case 'tool_call':
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
announcedToolCallIds.add(event.data.id)
}
}
break
case 'tool_generating':
if (event.toolCallId) {
startedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_result':
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_error':
logger.error(`[${tracker.requestId}] Tool error:`, {
toolCallId: event.toolCallId,
toolName: event.toolName,
error: event.error,
success: event.success,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'start':
if (event.data?.responseId) {
responseIdFromStart = event.data.responseId
}
break
case 'done':
if (event.data?.responseId) {
responseIdFromDone = event.data.responseId
lastDoneResponseId = responseIdFromDone
// Mark this done as safe only if no tool call is currently in progress or pending
const announced = announcedToolCallIds.size
const completed = completedToolExecutionIds.size
const started = startedToolExecutionIds.size
const hasToolInProgress = announced > completed || started > completed
if (!hasToolInProgress) {
lastSafeDoneResponseId = responseIdFromDone
}
}
break
case 'error':
break
default:
}
// Emit to client: rewrite 'error' events into user-friendly assistant message
if (event?.type === 'error') {
try {
const displayMessage: string =
(event?.data && (event.data.displayMessage as string)) ||
'Sorry, I encountered an error. Please try again.'
const formatted = `_${displayMessage}_`
// Accumulate so it persists to DB as assistant content
assistantContent += formatted
// Send as content chunk
try {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
)
)
} catch (enqueueErr) {
reader.cancel()
break
}
// Then close this response cleanly for the client
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
)
} catch (enqueueErr) {
reader.cancel()
break
}
} catch {}
// Do not forward the original error event
} else {
// Forward original event to client
try {
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
} catch (enqueueErr) {
reader.cancel()
break
}
}
} catch (e) {
// Enhanced error handling for large payloads and parsing issues
const lineLength = line.length
const isLargePayload = lineLength > 10000
if (isLargePayload) {
logger.error(
`[${tracker.requestId}] Failed to parse large SSE event (${lineLength} chars)`,
{
error: e,
preview: `${line.substring(0, 200)}...`,
size: lineLength,
}
)
} else {
logger.warn(
`[${tracker.requestId}] Failed to parse SSE event: "${line.substring(0, 200)}..."`,
e
)
}
}
} else if (line.trim() && line !== 'data: [DONE]') {
logger.debug(`[${tracker.requestId}] Non-SSE line from sim agent: "${line}"`)
}
}
// Start title generation if needed (runs in parallel)
if (capturedChatId && !capturedCurrentChat?.title && conversationHistory.length === 0) {
generateChatTitle(message)
.then(async (title) => {
if (title) {
await db
.update(copilotChats)
.set({ title, updatedAt: new Date() })
.where(eq(copilotChats.id, capturedChatId))
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
}
// Process any remaining buffer
if (buffer.trim()) {
logger.debug(`[${tracker.requestId}] Processing remaining buffer: "${buffer}"`)
if (buffer.startsWith('data: ')) {
try {
const jsonStr = buffer.slice(6)
const event = JSON.parse(jsonStr)
if (event.type === 'content' && event.data) {
assistantContent += event.data
}
// Forward remaining event, applying same error rewrite behavior
if (event?.type === 'error') {
const displayMessage: string =
(event?.data && (event.data.displayMessage as string)) ||
'Sorry, I encountered an error. Please try again.'
const formatted = `_${displayMessage}_`
assistantContent += formatted
try {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
)
)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
)
} catch (enqueueErr) {
reader.cancel()
}
} else {
try {
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
} catch (enqueueErr) {
reader.cancel()
}
}
} catch (e) {
logger.warn(`[${tracker.requestId}] Failed to parse final buffer: "${buffer}"`)
}
}
}
// Track accumulated content for final persistence
let accumulatedContent = ''
const accumulatedToolCalls: Array<{
id: string
name: string
args: Record<string, unknown>
state: string
result?: unknown
}> = []
// Log final streaming summary
logger.info(`[${tracker.requestId}] Streaming complete summary:`, {
totalContentLength: assistantContent.length,
toolCallsCount: toolCalls.length,
hasContent: assistantContent.length > 0,
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
})
const encoder = new TextEncoder()
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
// Server only updates conversationId here to avoid overwriting client's richer save.
if (currentChat) {
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
// Track if client is still connected
let clientConnected = true
if (responseId) {
await db
.update(copilotChats)
.set({
updatedAt: new Date(),
conversationId: responseId,
})
.where(eq(copilotChats.id, actualChatId!))
// Create the stream processing promise - this runs independently of client connection
// and is scheduled via after() to ensure it completes even if client disconnects
const streamProcessingPromise = transformStream(simAgentResponse.body!, {
streamId,
chatId: capturedChatId,
userId: authenticatedUserId,
workflowId,
userMessageId: userMessageIdToUse,
assistantMessageId,
logger.info(
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
{
updatedConversationId: responseId,
}
)
}
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
// Emit render events - try to send to client, always persist to Redis
onRenderEvent: async (event: RenderEvent) => {
const serialized = serializeRenderEvent(event)
// Send an error event to the client before closing so it knows what happened
// 1. Persist to Redis FIRST (critical for resumption)
appendChunk(streamId, serialized).catch(() => {})
// 2. Try to send to client if still connected (best effort)
if (clientConnected) {
try {
const errorMessage =
error instanceof Error && error.message === 'terminated'
? 'Connection to AI service was interrupted. Please try again.'
: 'An unexpected error occurred while processing the response.'
const encoder = new TextEncoder()
// Send error as content so it shows in the chat
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content', data: `\n\n_${errorMessage}_` })}\n\n`
)
)
// Send done event to properly close the stream on client
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
} catch (enqueueError) {
// Stream might already be closed, that's ok
logger.warn(
`[${tracker.requestId}] Could not send error event to client:`,
enqueueError
)
streamController?.enqueue(encoder.encode(serialized))
} catch {
// Client disconnected - mark as disconnected and continue processing
clientConnected = false
logger.info(`[${tracker.requestId}] Client disconnected, continuing server-side`, {
streamId,
})
}
} finally {
}
// Update stream metadata for specific events
switch (event.type) {
case 'text_delta':
accumulatedContent += (event as any).content || ''
appendContent(streamId, (event as any).content || '').catch(() => {})
break
case 'tool_pending':
updateToolCall(streamId, (event as any).toolCallId, {
id: (event as any).toolCallId,
name: (event as any).toolName,
args: (event as any).args || {},
state: 'pending',
}).catch(() => {})
break
case 'tool_executing':
updateToolCall(streamId, (event as any).toolCallId, {
state: 'executing',
}).catch(() => {})
break
case 'tool_success':
updateToolCall(streamId, (event as any).toolCallId, {
state: 'success',
result: (event as any).result,
}).catch(() => {})
accumulatedToolCalls.push({
id: (event as any).toolCallId,
name: (event as any).display?.label || '',
args: {},
state: 'success',
result: (event as any).result,
})
break
case 'tool_error':
updateToolCall(streamId, (event as any).toolCallId, {
state: 'error',
error: (event as any).error,
}).catch(() => {})
accumulatedToolCalls.push({
id: (event as any).toolCallId,
name: (event as any).display?.label || '',
args: {},
state: 'error',
})
break
}
},
onPersist: async (data) => {
if (data.type === 'message_complete') {
completeStream(streamId, undefined).catch(() => {})
}
},
// Never abort based on client - let stream complete
isAborted: () => false,
})
.then(() => {
if (capturedCurrentChat) {
db.update(copilotChats)
.set({ updatedAt: new Date() })
.where(eq(copilotChats.id, capturedChatId))
.catch(() => {})
}
logger.info(`[${tracker.requestId}] Stream processing complete`, {
streamId,
contentLength: accumulatedContent.length,
toolCallsCount: accumulatedToolCalls.length,
clientWasConnected: clientConnected,
})
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Stream error`, { streamId, error })
errorStream(streamId, error instanceof Error ? error.message : 'Unknown error').catch(
() => {}
)
})
// Use after() to ensure stream processing completes even if client disconnects
// This is critical for serverless environments where the handler might be killed
after(streamProcessingPromise)
// Controller reference for the client stream
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null
// Create ReadableStream for client - this is just a view into the processing
const readable = new ReadableStream({
start(controller) {
streamController = controller
// Prime the SSE stream to avoid buffering proxies
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {}
// When stream processing completes, close the client stream
streamProcessingPromise.finally(() => {
try {
controller.close()
} catch {
// Controller might already be closed
// Already closed
}
}
})
},
cancel() {
// Client cancelled the stream (e.g., navigated away)
clientConnected = false
logger.info(`[${tracker.requestId}] Client stream cancelled, server continues`, {
streamId,
})
},
})
const response = new Response(transformedStream, {
// Return direct SSE stream with streamId in header for resumption
logger.info(`[${tracker.requestId}] Returning direct SSE stream`, {
streamId,
chatId: capturedChatId,
})
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Stream-Id': streamId,
'X-Chat-Id': capturedChatId,
},
})
logger.info(`[${tracker.requestId}] Returning streaming response to client`, {
duration: tracker.getDuration(),
chatId: actualChatId,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
return response
}
// For non-streaming responses
@@ -899,7 +784,7 @@ export async function POST(req: NextRequest) {
// Save messages if we have a chat
if (currentChat && responseData.content) {
const userMessage = {
id: userMessageIdToUse, // Consistent ID used for request and persistence
id: userMessageIdToUse,
role: 'user',
content: message,
timestamp: new Date().toISOString(),

View File

@@ -224,7 +224,7 @@ export async function POST(req: NextRequest) {
hasApiKey: !!executionParams.apiKey,
})
const result = await executeTool(resolvedToolName, executionParams)
const result = await executeTool(resolvedToolName, executionParams, true)
logger.info(`[${tracker.requestId}] Tool execution complete`, {
toolName,

View File

@@ -0,0 +1,364 @@
import { db } from '@sim/db'
import { copilotChats, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_MODEL_IDS } from '@/lib/copilot/models'
import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import {
createStream,
completeStream,
errorStream,
updateStreamStatus,
} from '@/lib/copilot/stream-persistence'
import { executeToolServerSide, isServerExecutableTool } from '@/lib/copilot/tools/server/executor'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { env } from '@/lib/core/config/env'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
const logger = createLogger('HeadlessCopilotAPI')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const HeadlessRequestSchema = z.object({
message: z.string().min(1, 'Message is required'),
workflowId: z.string().min(1, 'Workflow ID is required'),
chatId: z.string().optional(),
model: z.enum(COPILOT_MODEL_IDS).optional(),
mode: z.enum(['agent', 'build', 'chat']).optional().default('agent'),
timeout: z.number().optional().default(300000), // 5 minute default
persistChanges: z.boolean().optional().default(true),
createNewChat: z.boolean().optional().default(false),
})
export const dynamic = 'force-dynamic'
export const fetchCache = 'force-no-store'
export const runtime = 'nodejs'
/**
* POST /api/copilot/headless
*
* Execute copilot completely server-side without any client connection.
* All tool calls are executed server-side and results are persisted directly.
*
* Returns the final result after all processing is complete.
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
const startTime = Date.now()
try {
// Authenticate via session or API key
let userId: string | null = null
const session = await getSession()
if (session?.user?.id) {
userId = session.user.id
} else {
// Try API key authentication from header
const apiKey = req.headers.get('x-api-key')
if (apiKey) {
const authResult = await authenticateApiKeyFromHeader(apiKey)
if (authResult.success && authResult.userId) {
userId = authResult.userId
// Update last used timestamp in background
if (authResult.keyId) {
updateApiKeyLastUsed(authResult.keyId).catch(() => {})
}
}
}
}
if (!userId) {
return createUnauthorizedResponse()
}
const body = await req.json()
const { message, workflowId, chatId, model, mode, timeout, persistChanges, createNewChat } =
HeadlessRequestSchema.parse(body)
logger.info(`[${tracker.requestId}] Headless copilot request`, {
userId,
workflowId,
messageLength: message.length,
mode,
})
// Verify user has access to workflow
const [wf] = await db
.select({ userId: workflowTable.userId, workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// TODO: Add proper workspace access check
if (wf.userId !== userId) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Load current workflow state from database
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Workflow data not found' }, { status: 404 })
}
const sanitizedWorkflow = sanitizeForCopilot({
blocks: workflowData.blocks,
edges: workflowData.edges,
loops: workflowData.loops,
parallels: workflowData.parallels,
})
// Create a stream for tracking (even in headless mode)
const streamId = crypto.randomUUID()
const userMessageId = crypto.randomUUID()
const assistantMessageId = crypto.randomUUID()
await createStream({
streamId,
chatId: chatId || '',
userId,
workflowId,
userMessageId,
isClientSession: false, // Key: this is headless
})
await updateStreamStatus(streamId, 'streaming')
// Handle chat persistence
let actualChatId = chatId
if (createNewChat && !chatId) {
const { provider, model: defaultModel } = getCopilotModel('chat')
const [newChat] = await db
.insert(copilotChats)
.values({
userId,
workflowId,
title: null,
model: model || defaultModel,
messages: [],
})
.returning()
if (newChat) {
actualChatId = newChat.id
}
}
// Get credentials for tools
let credentials: {
oauth: Record<string, { accessToken: string; accountId: string; name: string }>
apiKeys: string[]
} | null = null
try {
const rawCredentials = await getCredentialsServerTool.execute({ workflowId }, { userId })
const oauthMap: Record<string, { accessToken: string; accountId: string; name: string }> = {}
for (const cred of rawCredentials?.oauth?.connected?.credentials || []) {
if (cred.accessToken) {
oauthMap[cred.provider] = {
accessToken: cred.accessToken,
accountId: cred.id,
name: cred.name,
}
}
}
credentials = {
oauth: oauthMap,
apiKeys: rawCredentials?.environment?.variableNames || [],
}
} catch (error) {
logger.warn(`[${tracker.requestId}] Failed to fetch credentials`, { error })
}
// Build tool definitions
const { createUserToolSchema } = await import('@/tools/params')
const latestTools = getLatestVersionTools(tools)
const integrationTools = Object.entries(latestTools).map(([toolId, toolConfig]) => {
const userSchema = createUserToolSchema(toolConfig)
const strippedName = stripVersionSuffix(toolId)
return {
name: strippedName,
description: toolConfig.description || toolConfig.name || strippedName,
input_schema: userSchema,
defer_loading: true,
}
})
// Build request payload
const defaults = getCopilotModel('chat')
const selectedModel = model || defaults.model
const effectiveMode = mode === 'agent' ? 'build' : mode
const requestPayload = {
message,
workflowId,
userId,
stream: false, // Non-streaming for headless
model: selectedModel,
mode: effectiveMode,
version: SIM_AGENT_VERSION,
messageId: userMessageId,
...(actualChatId && { chatId: actualChatId }),
...(integrationTools.length > 0 && { tools: integrationTools }),
...(credentials && { credentials }),
}
// Call sim agent (non-streaming)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(requestPayload),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
logger.error(`[${tracker.requestId}] Sim agent error`, {
status: response.status,
error: errorText,
})
await errorStream(streamId, `Agent error: ${response.statusText}`)
return NextResponse.json(
{ error: `Agent error: ${response.statusText}` },
{ status: response.status }
)
}
const result = await response.json()
// Execute tool calls server-side
const toolResults: Record<string, { success: boolean; result?: unknown; error?: string }> = {}
if (result.toolCalls && Array.isArray(result.toolCalls)) {
for (const toolCall of result.toolCalls) {
const toolName = toolCall.name
const toolArgs = toolCall.arguments || toolCall.input || {}
logger.info(`[${tracker.requestId}] Executing tool server-side`, {
toolName,
toolCallId: toolCall.id,
})
if (!isServerExecutableTool(toolName)) {
logger.warn(`[${tracker.requestId}] Tool not executable server-side`, { toolName })
toolResults[toolCall.id] = {
success: false,
error: `Tool ${toolName} requires client-side execution`,
}
continue
}
const toolResult = await executeToolServerSide(
{ name: toolName, args: toolArgs },
{ workflowId, userId, persistChanges }
)
toolResults[toolCall.id] = toolResult
}
}
// Mark stream complete
await completeStream(streamId, { content: result.content, toolResults })
// Save to chat history
if (actualChatId && persistChanges) {
const [chat] = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.id, actualChatId))
.limit(1)
const existingMessages = chat ? (Array.isArray(chat.messages) ? chat.messages : []) : []
const newMessages = [
...existingMessages,
{
id: userMessageId,
role: 'user',
content: message,
timestamp: new Date().toISOString(),
},
{
id: assistantMessageId,
role: 'assistant',
content: result.content,
timestamp: new Date().toISOString(),
toolCalls: Object.entries(toolResults).map(([id, r]) => ({
id,
success: r.success,
})),
},
]
await db
.update(copilotChats)
.set({ messages: newMessages, updatedAt: new Date() })
.where(eq(copilotChats.id, actualChatId))
}
const duration = Date.now() - startTime
logger.info(`[${tracker.requestId}] Headless copilot complete`, {
duration,
contentLength: result.content?.length || 0,
toolCallsExecuted: Object.keys(toolResults).length,
})
return NextResponse.json({
success: true,
streamId,
chatId: actualChatId,
content: result.content,
toolResults,
duration,
})
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
await errorStream(streamId, 'Request timed out')
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
}
throw error
}
} catch (error) {
logger.error(`[${tracker.requestId}] Headless copilot error`, { error })
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,237 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import {
getStreamMetadata,
getStreamEvents,
getStreamEventCount,
getToolCallStates,
refreshStreamTTL,
checkAbortSignal,
abortStream,
} from '@/lib/copilot/stream-persistence'
const logger = createLogger('StreamResumeAPI')
interface RouteParams {
streamId: string
}
/**
* GET /api/copilot/stream/{streamId}
* Subscribe to or resume a stream
*
* Query params:
* - offset: Start from this event index (for resumption)
* - mode: 'sse' (default) or 'poll'
*/
export async function GET(req: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { streamId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const metadata = await getStreamMetadata(streamId)
if (!metadata) {
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
}
// Verify user owns this stream
if (metadata.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const offset = parseInt(req.nextUrl.searchParams.get('offset') || '0', 10)
const mode = req.nextUrl.searchParams.get('mode') || 'sse'
// Refresh TTL since someone is actively consuming
await refreshStreamTTL(streamId)
// Poll mode: return current state as JSON
if (mode === 'poll') {
const events = await getStreamEvents(streamId, offset)
const toolCalls = await getToolCallStates(streamId)
const eventCount = await getStreamEventCount(streamId)
return NextResponse.json({
metadata,
events,
toolCalls,
totalEvents: eventCount,
nextOffset: offset + events.length,
})
}
// SSE mode: stream events
const encoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
let closed = false
const safeEnqueue = (data: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(data))
} catch {
closed = true
}
}
const safeClose = () => {
if (closed) return
closed = true
try {
controller.close()
} catch {
// Already closed
}
}
// Send initial connection event
safeEnqueue(`: connected\n\n`)
// Send metadata
safeEnqueue(`event: metadata\ndata: ${JSON.stringify(metadata)}\n\n`)
// Send tool call states
const toolCalls = await getToolCallStates(streamId)
if (Object.keys(toolCalls).length > 0) {
safeEnqueue(`event: tool_states\ndata: ${JSON.stringify(toolCalls)}\n\n`)
}
// Replay missed events
const missedEvents = await getStreamEvents(streamId, offset)
for (const event of missedEvents) {
safeEnqueue(event)
}
// If stream is complete, send done and close
if (metadata.status === 'complete' || metadata.status === 'error' || metadata.status === 'aborted') {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({
status: metadata.status,
error: metadata.error,
})}\n\n`
)
safeClose()
return
}
// Stream is still active - poll for new events
let lastOffset = offset + missedEvents.length
const pollInterval = 100 // 100ms
const maxPollTime = 5 * 60 * 1000 // 5 minutes max
const startTime = Date.now()
const poll = async () => {
if (closed) return
try {
// Check for timeout
if (Date.now() - startTime > maxPollTime) {
logger.info('Stream poll timeout', { streamId })
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({ status: 'timeout' })}\n\n`
)
safeClose()
return
}
// Check if client disconnected
if (await checkAbortSignal(streamId)) {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({ status: 'aborted' })}\n\n`
)
safeClose()
return
}
// Get current metadata to check status
const currentMeta = await getStreamMetadata(streamId)
if (!currentMeta) {
safeClose()
return
}
// Get new events
const newEvents = await getStreamEvents(streamId, lastOffset)
for (const event of newEvents) {
safeEnqueue(event)
}
lastOffset += newEvents.length
// Refresh TTL
await refreshStreamTTL(streamId)
// If complete, send status and close
if (
currentMeta.status === 'complete' ||
currentMeta.status === 'error' ||
currentMeta.status === 'aborted'
) {
safeEnqueue(
`event: stream_status\ndata: ${JSON.stringify({
status: currentMeta.status,
error: currentMeta.error,
})}\n\n`
)
safeClose()
return
}
// Continue polling
setTimeout(poll, pollInterval)
} catch (error) {
logger.error('Stream poll error', { streamId, error })
safeClose()
}
}
// Start polling
setTimeout(poll, pollInterval)
},
})
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Stream-Id': streamId,
},
})
}
/**
* DELETE /api/copilot/stream/{streamId}
* Abort a stream
*/
export async function DELETE(req: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { streamId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const metadata = await getStreamMetadata(streamId)
if (!metadata) {
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
}
// Verify user owns this stream
if (metadata.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await abortStream(streamId)
logger.info('Stream aborted by user', { streamId, userId: session.user.id })
return NextResponse.json({ success: true, streamId })
}

View File

@@ -6,10 +6,9 @@ import { createLogger } from '@sim/logger'
import binaryExtensionsList from 'binary-extensions'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import {
@@ -22,7 +21,6 @@ import {
} from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
import '@/lib/uploads/core/setup.server'
export const dynamic = 'force-dynamic'
@@ -32,12 +30,6 @@ const logger = createLogger('FilesParseAPI')
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
interface ExecutionContext {
workspaceId: string
workflowId: string
executionId: string
}
interface ParseResult {
success: boolean
content?: string
@@ -45,7 +37,6 @@ interface ParseResult {
filePath: string
originalName?: string // Original filename from database (for workspace files)
viewerUrl?: string | null // Viewer URL for the file if available
userFile?: UserFile // UserFile object for the raw file
metadata?: {
fileType: string
size: number
@@ -79,45 +70,27 @@ export async function POST(request: NextRequest) {
const userId = authResult.userId
const requestData = await request.json()
const { filePath, fileType, workspaceId, workflowId, executionId } = requestData
const { filePath, fileType, workspaceId } = requestData
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
}
// Build execution context if all required fields are present
const executionContext: ExecutionContext | undefined =
workspaceId && workflowId && executionId
? { workspaceId, workflowId, executionId }
: undefined
logger.info('File parse request received:', {
filePath,
fileType,
workspaceId,
userId,
hasExecutionContext: !!executionContext,
})
logger.info('File parse request received:', { filePath, fileType, workspaceId, userId })
if (Array.isArray(filePath)) {
const results = []
for (const singlePath of filePath) {
if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) {
for (const path of filePath) {
if (!path || (typeof path === 'string' && path.trim() === '')) {
results.push({
success: false,
error: 'Empty file path in array',
filePath: singlePath || '',
filePath: path || '',
})
continue
}
const result = await parseFileSingle(
singlePath,
fileType,
workspaceId,
userId,
executionContext
)
const result = await parseFileSingle(path, fileType, workspaceId, userId)
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
@@ -133,7 +106,6 @@ export async function POST(request: NextRequest) {
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false,
file: result.userFile,
},
filePath: result.filePath,
viewerUrl: result.viewerUrl,
@@ -149,7 +121,7 @@ export async function POST(request: NextRequest) {
})
}
const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
const result = await parseFileSingle(filePath, fileType, workspaceId, userId)
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
@@ -165,7 +137,6 @@ export async function POST(request: NextRequest) {
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false,
file: result.userFile,
},
filePath: result.filePath,
viewerUrl: result.viewerUrl,
@@ -193,8 +164,7 @@ async function parseFileSingle(
filePath: string,
fileType: string,
workspaceId: string,
userId: string,
executionContext?: ExecutionContext
userId: string
): Promise<ParseResult> {
logger.info('Parsing file:', filePath)
@@ -216,18 +186,18 @@ async function parseFileSingle(
}
if (filePath.includes('/api/files/serve/')) {
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
return handleCloudFile(filePath, fileType, undefined, userId)
}
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
return handleExternalUrl(filePath, fileType, workspaceId, userId)
}
if (isUsingCloudStorage()) {
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
return handleCloudFile(filePath, fileType, undefined, userId)
}
return handleLocalFile(filePath, fileType, userId, executionContext)
return handleLocalFile(filePath, fileType, userId)
}
/**
@@ -260,14 +230,12 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
/**
* Handle external URL
* If workspaceId is provided, checks if file already exists and saves to workspace if not
* If executionContext is provided, also stores the file in execution storage and returns UserFile
*/
async function handleExternalUrl(
url: string,
fileType: string,
workspaceId: string,
userId: string,
executionContext?: ExecutionContext
userId: string
): Promise<ParseResult> {
try {
logger.info('Fetching external URL:', url)
@@ -344,13 +312,17 @@ async function handleExternalUrl(
if (existingFile) {
const storageFilePath = `/api/files/serve/${existingFile.key}`
return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext)
return handleCloudFile(storageFilePath, fileType, 'workspace', userId)
}
}
}
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
timeout: DOWNLOAD_TIMEOUT_MS,
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Host: urlValidation.originalHostname!,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
@@ -369,19 +341,6 @@ async function handleExternalUrl(
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
let userFile: UserFile | undefined
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
if (executionContext) {
try {
userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId)
logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to store file in execution storage:`, uploadError)
// Continue without userFile - parsing can still work
}
}
if (shouldCheckWorkspace) {
try {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
@@ -394,6 +353,8 @@ async function handleExternalUrl(
})
} else {
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
const mimeType =
response.headers.get('content-type') || getMimeTypeFromExtension(extension)
await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType)
logger.info(`Saved URL file to workspace storage: ${filename}`)
}
@@ -402,23 +363,17 @@ async function handleExternalUrl(
}
}
let parseResult: ParseResult
if (extension === 'pdf') {
parseResult = await handlePdfBuffer(buffer, filename, fileType, url)
} else if (extension === 'csv') {
parseResult = await handleCsvBuffer(buffer, filename, fileType, url)
} else if (isSupportedFileType(extension)) {
parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
} else {
parseResult = handleGenericBuffer(buffer, filename, extension, fileType)
return await handlePdfBuffer(buffer, filename, fileType, url)
}
if (extension === 'csv') {
return await handleCsvBuffer(buffer, filename, fileType, url)
}
if (isSupportedFileType(extension)) {
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
}
// Attach userFile to the result
if (userFile) {
parseResult.userFile = userFile
}
return parseResult
return handleGenericBuffer(buffer, filename, extension, fileType)
} catch (error) {
logger.error(`Error handling external URL ${url}:`, error)
return {
@@ -431,15 +386,12 @@ async function handleExternalUrl(
/**
* Handle file stored in cloud storage
* If executionContext is provided and file is not already from execution storage,
* copies the file to execution storage and returns UserFile
*/
async function handleCloudFile(
filePath: string,
fileType: string,
explicitContext: string | undefined,
userId: string,
executionContext?: ExecutionContext
userId: string
): Promise<ParseResult> {
try {
const cloudKey = extractStorageKey(filePath)
@@ -486,7 +438,6 @@ async function handleCloudFile(
const filename = originalFilename || cloudKey.split('/').pop() || cloudKey
const extension = path.extname(filename).toLowerCase().substring(1)
const mimeType = getMimeTypeFromExtension(extension)
const normalizedFilePath = `/api/files/serve/${encodeURIComponent(cloudKey)}?context=${context}`
let workspaceIdFromKey: string | undefined
@@ -502,39 +453,6 @@ async function handleCloudFile(
const viewerUrl = getViewerUrl(cloudKey, workspaceIdFromKey)
// Store file in execution storage if executionContext is provided
let userFile: UserFile | undefined
if (executionContext) {
// If file is already from execution context, create UserFile reference without re-uploading
if (context === 'execution') {
userFile = {
id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
name: filename,
url: normalizedFilePath,
size: fileBuffer.length,
type: mimeType,
key: cloudKey,
context: 'execution',
}
logger.info(`Created UserFile reference for existing execution file: ${filename}`)
} else {
// Copy from workspace/other storage to execution storage
try {
userFile = await uploadExecutionFile(
executionContext,
fileBuffer,
filename,
mimeType,
userId
)
logger.info(`Copied file to execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to copy file to execution storage:`, uploadError)
}
}
}
let parseResult: ParseResult
if (extension === 'pdf') {
parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath)
@@ -559,11 +477,6 @@ async function handleCloudFile(
parseResult.viewerUrl = viewerUrl
// Attach userFile to the result
if (userFile) {
parseResult.userFile = userFile
}
return parseResult
} catch (error) {
logger.error(`Error handling cloud file ${filePath}:`, error)
@@ -587,8 +500,7 @@ async function handleCloudFile(
async function handleLocalFile(
filePath: string,
fileType: string,
userId: string,
executionContext?: ExecutionContext
userId: string
): Promise<ParseResult> {
try {
const filename = filePath.split('/').pop() || filePath
@@ -628,32 +540,13 @@ async function handleLocalFile(
const hash = createHash('md5').update(fileBuffer).digest('hex')
const extension = path.extname(filename).toLowerCase().substring(1)
const mimeType = fileType || getMimeTypeFromExtension(extension)
// Store file in execution storage if executionContext is provided
let userFile: UserFile | undefined
if (executionContext) {
try {
userFile = await uploadExecutionFile(
executionContext,
fileBuffer,
filename,
mimeType,
userId
)
logger.info(`Stored local file in execution storage: ${filename}`, { key: userFile.key })
} catch (uploadError) {
logger.warn(`Failed to store local file in execution storage:`, uploadError)
}
}
return {
success: true,
content: result.content,
filePath,
userFile,
metadata: {
fileType: mimeType,
fileType: fileType || getMimeTypeFromExtension(extension),
size: stats.size,
hash,
processingTime: 0,

View File

@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
if (!startBlock) {
return []

View File

@@ -276,11 +276,8 @@ describe('Function Execute API Route', () => {
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', {
code: 'return <email>',
blockData: {
'block-123': { id: '123', subject: 'Test Email' },
},
blockNameMapping: {
email: 'block-123',
params: {
email: { id: '123', subject: 'Test Email' },
},
})
@@ -308,13 +305,9 @@ describe('Function Execute API Route', () => {
it.concurrent('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
blockData: {
'block-1': 'hello',
'block-2': 'world',
},
blockNameMapping: {
validVar: 'block-1',
another_valid: 'block-2',
params: {
validVar: 'hello',
another_valid: 'world',
},
})
@@ -328,22 +321,28 @@ describe('Function Execute API Route', () => {
it.concurrent(
'should handle Gmail webhook data with email addresses containing angle brackets',
async () => {
const emailData = {
id: '123',
from: 'Waleed Latif <waleed@sim.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
bodyText: 'Hello world',
const gmailData = {
email: {
id: '123',
from: 'Waleed Latif <waleed@sim.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
bodyText: 'Hello world',
},
rawEmail: {
id: '123',
payload: {
headers: [
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
{ name: 'To', value: 'User <user@example.com>' },
],
},
},
}
const req = createMockRequest('POST', {
code: 'return <email>',
blockData: {
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
params: gmailData,
})
const response = await POST(req)
@@ -357,20 +356,17 @@ describe('Function Execute API Route', () => {
it.concurrent(
'should properly serialize complex email objects with special characters',
async () => {
const emailData = {
from: 'Test User <test@example.com>',
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
const complexEmailData = {
email: {
from: 'Test User <test@example.com>',
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
},
}
const req = createMockRequest('POST', {
code: 'return <email>',
blockData: {
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
params: complexEmailData,
})
const response = await POST(req)
@@ -523,23 +519,18 @@ describe('Function Execute API Route', () => {
})
it.concurrent('should handle JSON serialization edge cases', async () => {
const complexData = {
special: 'chars"with\'quotes',
unicode: '🎉 Unicode content',
nested: {
deep: {
value: 'test',
},
},
}
const req = createMockRequest('POST', {
code: 'return <complexData>',
blockData: {
'block-complex': complexData,
},
blockNameMapping: {
complexData: 'block-complex',
params: {
complexData: {
special: 'chars"with\'quotes',
unicode: '🎉 Unicode content',
nested: {
deep: {
value: 'test',
},
},
},
},
})

View File

@@ -9,8 +9,8 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation'
import { navigatePath } from '@/executor/variables/resolvers/reference'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
const logger = createLogger('FunctionExecuteAPI')
const E2B_JS_WRAPPER_LINES = 3
const E2B_PYTHON_WRAPPER_LINES = 1
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
type TypeScriptModule = typeof import('typescript')
@@ -134,21 +134,33 @@ function extractEnhancedError(
if (error.stack) {
enhanced.stack = error.stack
// Parse stack trace to extract line and column information
// Handle both compilation errors and runtime errors
const stackLines: string[] = error.stack.split('\n')
for (const line of stackLines) {
// Pattern 1: Compilation errors - "user-function.js:6"
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
// Pattern 2: Runtime errors - "at user-function.js:5:12"
if (!match) {
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
}
// Pattern 3: Generic patterns for any line containing our filename
if (!match) {
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
}
if (match) {
const stackLine = Number.parseInt(match[1], 10)
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
// Adjust line number to account for wrapper code
// The user code starts at a specific line in our wrapper
const adjustedLine = stackLine - userCodeStartLine + 1
// Check if this is a syntax error in wrapper code caused by incomplete user code
const isWrapperSyntaxError =
stackLine > userCodeStartLine &&
error.name === 'SyntaxError' &&
@@ -156,6 +168,7 @@ function extractEnhancedError(
error.message.includes('Unexpected end of input'))
if (isWrapperSyntaxError && userCode) {
// Map wrapper syntax errors to the last line of user code
const codeLines = userCode.split('\n')
const lastUserLine = codeLines.length
enhanced.line = lastUserLine
@@ -168,6 +181,7 @@ function extractEnhancedError(
enhanced.line = adjustedLine
enhanced.column = stackColumn
// Extract the actual line content from user code
if (userCode) {
const codeLines = userCode.split('\n')
if (adjustedLine <= codeLines.length) {
@@ -178,6 +192,7 @@ function extractEnhancedError(
}
if (stackLine <= userCodeStartLine) {
// Error is in wrapper code itself
enhanced.line = stackLine
enhanced.column = stackColumn
break
@@ -185,6 +200,7 @@ function extractEnhancedError(
}
}
// Clean up stack trace to show user-relevant information
const cleanedStackLines: string[] = stackLines
.filter(
(line: string) =>
@@ -198,6 +214,9 @@ function extractEnhancedError(
}
}
// Keep original message without adding error type prefix
// The error type will be added later in createUserFriendlyErrorMessage
return enhanced
}
@@ -212,6 +231,7 @@ function formatE2BError(
userCode: string,
prologueLineCount: number
): { formattedError: string; cleanedOutput: string } {
// Calculate line offset based on language and prologue
const wrapperLines =
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
const totalOffset = prologueLineCount + wrapperLines
@@ -221,20 +241,27 @@ function formatE2BError(
let cleanErrorMsg = ''
if (language === CodeLanguage.Python) {
// Python error format: "Cell In[X], line Y" followed by error details
// Extract line number from the Cell reference
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
if (cellMatch) {
const originalLine = Number.parseInt(cellMatch[1], 10)
userLine = originalLine - totalOffset
}
// Extract clean error message from the error string
// Remove file references like "(detected at line X) (file.py, line Y)"
cleanErrorMsg = errorMessage
.replace(/\s*\(detected at line \d+\)/g, '')
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
.trim()
} else if (language === CodeLanguage.JavaScript) {
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
// First, extract the error type and message from the first line
const firstLineEnd = errorMessage.indexOf('\n')
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
if (jsErrorMatch) {
cleanErrorType = jsErrorMatch[1]
@@ -242,11 +269,13 @@ function formatE2BError(
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
userLine = originalLine - totalOffset
} else {
// Fallback: look for line number in the arrow pointer line (> 11 |)
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
if (arrowMatch) {
const originalLine = Number.parseInt(arrowMatch[1], 10)
userLine = originalLine - totalOffset
}
// Try to extract error type and message
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
if (errorMatch) {
cleanErrorType = errorMatch[1]
@@ -260,11 +289,13 @@ function formatE2BError(
}
}
// Build the final clean error message
const finalErrorMsg =
cleanErrorType && cleanErrorMsg
? `${cleanErrorType}: ${cleanErrorMsg}`
: cleanErrorMsg || errorMessage
// Format with line number if available
let formattedError = finalErrorMsg
if (userLine && userLine > 0) {
const codeLines = userCode.split('\n')
@@ -280,6 +311,7 @@ function formatE2BError(
}
}
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput }
@@ -295,6 +327,7 @@ function createUserFriendlyErrorMessage(
): string {
let errorMessage = enhanced.message
// Add line information if available
if (enhanced.line !== undefined) {
let lineInfo = `Line ${enhanced.line}`
@@ -305,14 +338,18 @@ function createUserFriendlyErrorMessage(
errorMessage = `${lineInfo} - ${errorMessage}`
} else {
// If no line number, try to extract it from stack trace for display
if (enhanced.stack) {
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
if (stackMatch) {
const line = Number.parseInt(stackMatch[1], 10)
let lineInfo = `Line ${line}`
// Try to get line content if we have userCode
if (userCode) {
const codeLines = userCode.split('\n')
// Note: stackMatch gives us VM line number, need to adjust
// This is a fallback case, so we might not have perfect line mapping
if (line <= codeLines.length) {
const lineContent = codeLines[line - 1]?.trim()
if (lineContent) {
@@ -326,6 +363,7 @@ function createUserFriendlyErrorMessage(
}
}
// Add error type prefix with consistent naming
if (enhanced.name !== 'Error') {
const errorTypePrefix =
enhanced.name === 'SyntaxError'
@@ -336,6 +374,7 @@ function createUserFriendlyErrorMessage(
? 'Reference Error'
: enhanced.name
// Only add prefix if not already present
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
errorMessage = `${errorTypePrefix}: ${errorMessage}`
}
@@ -344,6 +383,9 @@ function createUserFriendlyErrorMessage(
return errorMessage
}
/**
* Resolves workflow variables with <variable.name> syntax
*/
function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
@@ -363,35 +405,39 @@ function resolveWorkflowVariables(
while ((match = regex.exec(code)) !== null) {
const variableName = match[1].trim()
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => normalizeName(variable.name || '') === variableName
)
if (!foundVariable) {
const availableVars = Object.values(workflowVariables)
.map((v) => v.name)
.filter(Boolean)
throw new Error(
`Variable "${variableName}" doesn't exist.` +
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
)
}
let variableValue: unknown = ''
if (foundVariable) {
const variable = foundVariable[1]
variableValue = variable.value
const variable = foundVariable[1]
let variableValue: unknown = variable.value
if (variable.value !== undefined && variable.value !== null) {
const type = variable.type === 'string' ? 'plain' : variable.type
if (type === 'number') {
variableValue = Number(variableValue)
} else if (type === 'boolean') {
variableValue = variableValue === 'true' || variableValue === true
} else if (type === 'json' && typeof variableValue === 'string') {
if (variable.value !== undefined && variable.value !== null) {
try {
variableValue = JSON.parse(variableValue)
// Handle 'string' type the same as 'plain' for backward compatibility
const type = variable.type === 'string' ? 'plain' : variable.type
// For plain text, use exactly what's entered without modifications
if (type === 'plain' && typeof variableValue === 'string') {
// Use as-is for plain text
} else if (type === 'number') {
variableValue = Number(variableValue)
} else if (type === 'boolean') {
variableValue = variableValue === 'true' || variableValue === true
} else if (type === 'json') {
try {
variableValue =
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
} catch {
// Keep original value if JSON parsing fails
}
}
} catch {
// Keep as-is
// Fallback to original value on error
variableValue = variable.value
}
}
}
@@ -404,9 +450,11 @@ function resolveWorkflowVariables(
})
}
// Process replacements in reverse order to maintain correct indices
for (let i = replacements.length - 1; i >= 0; i--) {
const { match: matchStr, index, variableName, variableValue } = replacements[i]
// Use variable reference approach
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue
resolvedCode =
@@ -416,6 +464,9 @@ function resolveWorkflowVariables(
return resolvedCode
}
/**
* Resolves environment variables with {{var_name}} syntax
*/
function resolveEnvironmentVariables(
code: string,
params: Record<string, any>,
@@ -431,28 +482,32 @@ function resolveEnvironmentVariables(
const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (value) {
resolverVars[key] = String(value)
}
})
Object.entries(envVars).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (value) {
resolverVars[key] = value
}
})
while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim()
if (!(varName in resolverVars)) {
continue
}
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
replacements.push({
match: match[0],
index: match.index,
varName,
varValue: resolverVars[varName],
varValue: String(varValue),
})
}
@@ -468,8 +523,12 @@ function resolveEnvironmentVariables(
return resolvedCode
}
/**
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
*/
function resolveTagVariables(
code: string,
params: Record<string, any>,
blockData: Record<string, any>,
blockNameMapping: Record<string, string>,
contextVariables: Record<string, any>
@@ -484,30 +543,27 @@ function resolveTagVariables(
for (const match of tagMatches) {
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const blockName = pathParts[0]
const blockId = blockNameMapping[blockName]
if (!blockId) {
continue
}
const blockOutput = blockData[blockId]
if (blockOutput === undefined) {
continue
}
let tagValue: any
if (pathParts.length === 1) {
tagValue = blockOutput
} else {
tagValue = navigatePath(blockOutput, pathParts.slice(1))
}
if (tagValue === undefined) {
continue
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
// First try params, then blockData directly, then try with block name mapping
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
// If not found and the path starts with a block name, try mapping the block name to ID
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
// Direct lookup using normalized block name
const blockId = blockNameMapping[normalizedBlockName] ?? null
if (blockId) {
const remainingPath = pathParts.slice(1).join('.')
const fullPath = `${blockId}.${remainingPath}`
tagValue = getNestedValue(blockData, fullPath) || ''
}
}
// If the value is a stringified JSON, parse it back to object
if (
typeof tagValue === 'string' &&
tagValue.length > 100 &&
@@ -515,13 +571,16 @@ function resolveTagVariables(
) {
try {
tagValue = JSON.parse(tagValue)
} catch {
// Keep as-is
} catch (e) {
// Keep as string if parsing fails
}
}
// Instead of injecting large JSON directly, create a variable reference
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = tagValue
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
@@ -546,13 +605,35 @@ function resolveCodeVariables(
let resolvedCode = code
const contextVariables: Record<string, any> = {}
// Resolve workflow variables with <variable.name> syntax first
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
// Resolve environment variables with {{var_name}} syntax
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
resolvedCode = resolveTagVariables(
resolvedCode,
params,
blockData,
blockNameMapping,
contextVariables
)
return { resolvedCode, contextVariables }
}
/**
* Get nested value from object using dot notation path
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined
return path.split('.').reduce((current, key) => {
return current && typeof current === 'object' ? current[key] : undefined
}, obj)
}
/**
* Remove one trailing newline from stdout
* This handles the common case where print() or console.log() adds a trailing \n
@@ -590,6 +671,7 @@ export async function POST(req: NextRequest) {
isCustomTool = false,
} = body
// Extract internal parameters that shouldn't be passed to the execution context
const executionParams = { ...params }
executionParams._context = undefined
@@ -615,6 +697,7 @@ export async function POST(req: NextRequest) {
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
let jsImports = ''
let jsRemainingCode = resolvedCode
let hasImports = false
@@ -624,22 +707,31 @@ export async function POST(req: NextRequest) {
jsImports = extractionResult.imports
jsRemainingCode = extractionResult.remainingCode
// Check for ES6 imports or CommonJS require statements
// ES6 imports are extracted by the TypeScript parser
// Also check for require() calls which indicate external dependencies
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
hasImports = jsImports.trim().length > 0 || hasRequireStatements
}
// Python always requires E2B
if (lang === CodeLanguage.Python && !isE2bEnabled) {
throw new Error(
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
)
}
// JavaScript with imports requires E2B
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
throw new Error(
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
)
}
// Use E2B if:
// - E2B is enabled AND
// - Not a custom tool AND
// - (Python OR JavaScript with imports)
const useE2B =
isE2bEnabled &&
!isCustomTool &&
@@ -652,10 +744,13 @@ export async function POST(req: NextRequest) {
language: lang,
})
let prologue = ''
const epilogue = ''
if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0
// Reuse the imports we already extracted earlier
const imports = jsImports
const remainingCode = jsRemainingCode
@@ -687,7 +782,7 @@ export async function POST(req: NextRequest) {
' }',
'})();',
].join('\n')
const codeForE2B = importSection + prologue + wrapped
const codeForE2B = importSection + prologue + wrapped + epilogue
const execStart = Date.now()
const {
@@ -709,6 +804,7 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -732,7 +828,7 @@ export async function POST(req: NextRequest) {
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
})
}
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += 'import json\n'
prologueLineCount++
@@ -750,7 +846,7 @@ export async function POST(req: NextRequest) {
'__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n')
const codeForE2B = prologue + wrapped
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
@@ -772,6 +868,7 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -800,6 +897,7 @@ export async function POST(req: NextRequest) {
const wrapperLines = ['(async () => {', ' try {']
if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`)
})
@@ -833,10 +931,12 @@ export async function POST(req: NextRequest) {
})
const ivmError = isolatedResult.error
// Adjust line number for prepended param destructuring in custom tools
let adjustedLine = ivmError.line
let adjustedLineContent = ivmError.lineContent
if (prependedLineCount > 0 && ivmError.line !== undefined) {
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
// Get line content from original user code, not the prepended code
const codeLines = resolvedCode.split('\n')
if (adjustedLine <= codeLines.length) {
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()

View File

@@ -0,0 +1,395 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { isDev } from '@/lib/core/config/feature-flags'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { executeTool } from '@/tools'
import { getTool, validateRequiredParametersAfterMerge } from '@/tools/utils'
const logger = createLogger('ProxyAPI')
const proxyPostSchema = z.object({
toolId: z.string().min(1, 'toolId is required'),
params: z.record(z.any()).optional().default({}),
executionContext: z
.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
executionId: z.string().optional(),
userId: z.string().optional(),
})
.optional(),
})
/**
* Creates a minimal set of default headers for proxy requests
* @returns Record of HTTP headers
*/
const getProxyHeaders = (): Record<string, string> => {
return {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
}
}
/**
* Formats a response with CORS headers
* @param responseData Response data object
* @param status HTTP status code
* @returns NextResponse with CORS headers
*/
const formatResponse = (responseData: any, status = 200) => {
return NextResponse.json(responseData, {
status,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
/**
* Creates an error response with consistent formatting
* @param error Error object or message
* @param status HTTP status code
* @param additionalData Additional data to include in the response
* @returns Formatted error response
*/
const createErrorResponse = (error: any, status = 500, additionalData = {}) => {
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
logger.error('Creating error response', {
errorMessage,
status,
stack: isDev ? errorStack : undefined,
})
return formatResponse(
{
success: false,
error: errorMessage,
stack: isDev ? errorStack : undefined,
...additionalData,
},
status
)
}
/**
* GET handler for direct external URL proxying
* This allows for GET requests to external APIs
*/
export async function GET(request: Request) {
const url = new URL(request.url)
const targetUrl = url.searchParams.get('url')
const requestId = generateRequestId()
// Vault download proxy: /api/proxy?vaultDownload=1&bucket=...&object=...&credentialId=...
const vaultDownload = url.searchParams.get('vaultDownload')
if (vaultDownload === '1') {
try {
const bucket = url.searchParams.get('bucket')
const objectParam = url.searchParams.get('object')
const credentialId = url.searchParams.get('credentialId')
if (!bucket || !objectParam || !credentialId) {
return createErrorResponse('Missing bucket, object, or credentialId', 400)
}
// Fetch access token using existing token API
const baseUrl = new URL(getBaseUrl())
const tokenUrl = new URL('/api/auth/oauth/token', baseUrl)
// Build headers: forward session cookies if present; include internal auth for server-side
const tokenHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
const incomingCookie = request.headers.get('cookie')
if (incomingCookie) tokenHeaders.Cookie = incomingCookie
try {
const internalToken = await generateInternalToken()
tokenHeaders.Authorization = `Bearer ${internalToken}`
} catch (_e) {
// best-effort internal auth
}
// Optional workflow context for collaboration auth
const workflowId = url.searchParams.get('workflowId') || undefined
const tokenRes = await fetch(tokenUrl.toString(), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({ credentialId, workflowId }),
})
if (!tokenRes.ok) {
const err = await tokenRes.text()
return createErrorResponse(`Failed to fetch access token: ${err}`, 401)
}
const tokenJson = await tokenRes.json()
const accessToken = tokenJson.accessToken
if (!accessToken) {
return createErrorResponse('No access token available', 401)
}
// Avoid double-encoding: incoming object may already be percent-encoded
const objectDecoded = decodeURIComponent(objectParam)
const gcsUrl = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(
bucket
)}/o/${encodeURIComponent(objectDecoded)}?alt=media`
const fileRes = await fetch(gcsUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!fileRes.ok) {
const errText = await fileRes.text()
return createErrorResponse(errText || 'Failed to download file', fileRes.status)
}
const headers = new Headers()
fileRes.headers.forEach((v, k) => headers.set(k, v))
return new NextResponse(fileRes.body, { status: 200, headers })
} catch (error: any) {
logger.error(`[${requestId}] Vault download proxy failed`, {
error: error instanceof Error ? error.message : String(error),
})
return createErrorResponse('Vault download failed', 500)
}
}
if (!targetUrl) {
logger.error(`[${requestId}] Missing 'url' parameter`)
return createErrorResponse("Missing 'url' parameter", 400)
}
const urlValidation = await validateUrlWithDNS(targetUrl)
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Blocked proxy request`, {
url: targetUrl.substring(0, 100),
error: urlValidation.error,
})
return createErrorResponse(urlValidation.error || 'Invalid URL', 403)
}
const method = url.searchParams.get('method') || 'GET'
const bodyParam = url.searchParams.get('body')
let body: string | undefined
if (bodyParam && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
try {
body = decodeURIComponent(bodyParam)
} catch (error) {
logger.warn(`[${requestId}] Failed to decode body parameter`, error)
}
}
const customHeaders: Record<string, string> = {}
for (const [key, value] of url.searchParams.entries()) {
if (key.startsWith('header.')) {
const headerName = key.substring(7)
customHeaders[headerName] = value
}
}
if (body && !customHeaders['Content-Type']) {
customHeaders['Content-Type'] = 'application/json'
}
logger.info(`[${requestId}] Proxying ${method} request to: ${targetUrl}`)
try {
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
method: method,
headers: {
...getProxyHeaders(),
...customHeaders,
Host: urlValidation.originalHostname!,
},
body: body || undefined,
})
const contentType = response.headers.get('content-type') || ''
let data
if (contentType.includes('application/json')) {
data = await response.json()
} else {
data = await response.text()
}
const errorMessage = !response.ok
? data && typeof data === 'object' && data.error
? `${data.error.message || JSON.stringify(data.error)}`
: response.statusText || `HTTP error ${response.status}`
: undefined
if (!response.ok) {
logger.error(`[${requestId}] External API error: ${response.status} ${response.statusText}`)
}
return formatResponse({
success: response.ok,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
data,
error: errorMessage,
})
} catch (error: any) {
logger.error(`[${requestId}] Proxy GET request failed`, {
url: targetUrl,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return createErrorResponse(error)
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const startTime = new Date()
const startTimeISO = startTime.toISOString()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
return createErrorResponse('Unauthorized', 401)
}
let requestBody
try {
requestBody = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
throw new Error('Invalid JSON in request body')
}
const validationResult = proxyPostSchema.safeParse(requestBody)
if (!validationResult.success) {
logger.error(`[${requestId}] Request validation failed`, {
errors: validationResult.error.errors,
})
const errorMessages = validationResult.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
throw new Error(`Validation failed: ${errorMessages}`)
}
const { toolId, params } = validationResult.data
logger.info(`[${requestId}] Processing tool: ${toolId}`)
const tool = getTool(toolId)
if (!tool) {
logger.error(`[${requestId}] Tool not found: ${toolId}`)
throw new Error(`Tool not found: ${toolId}`)
}
try {
validateRequiredParametersAfterMerge(toolId, tool, params)
} catch (validationError) {
logger.warn(`[${requestId}] Tool validation failed for ${toolId}`, {
error: validationError instanceof Error ? validationError.message : String(validationError),
})
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return createErrorResponse(validationError, 400, {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
const hasFileOutputs =
tool.outputs &&
Object.values(tool.outputs).some(
(output) => output.type === 'file' || output.type === 'file[]'
)
const result = await executeTool(
toolId,
params,
true, // skipProxy (we're already in the proxy)
!hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs)
undefined // execution context is not available in proxy context
)
if (!result.success) {
logger.warn(`[${requestId}] Tool execution failed for ${toolId}`, {
error: result.error || 'Unknown error',
})
throw new Error(result.error || 'Tool execution failed')
}
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
const responseWithTimingData = {
...result,
startTime: startTimeISO,
endTime: endTimeISO,
duration,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
return formatResponse(responseWithTimingData)
} catch (error: any) {
logger.error(`[${requestId}] Proxy request failed`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
})
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return createErrorResponse(error, 500, {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
})
}

View File

@@ -1,138 +0,0 @@
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 })
}
}

View File

@@ -1,276 +0,0 @@
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 })
}
}

View File

@@ -1,681 +0,0 @@
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<typeof BatchInsertRowsSchema>,
userId: string
): Promise<NextResponse> {
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<string, unknown>).rows)
) {
return handleBatchInsert(
requestId,
tableId,
body as z.infer<typeof BatchInsertRowsSchema>,
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<string, unknown> | undefined
let sort: Sort | undefined
try {
if (filterParam) {
filter = JSON.parse(filterParam) as Record<string, unknown>
}
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<number>`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 }
)
}
}
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
if (uniqueColumns.length > 0) {
// If updating multiple rows, check that updateData doesn't set any unique column
// (would cause all rows to have the same value, violating uniqueness)
if (matchingRows.length > 1) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
if (uniqueColumnsInUpdate.length > 0) {
return NextResponse.json(
{
error: 'Cannot set unique column values when updating multiple rows',
details: [
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
],
},
{ status: 400 }
)
}
}
// Check unique constraints against database for each row
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 })
}
}

View File

@@ -1,182 +0,0 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or, 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<typeof f, null> => 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),
or(...validUniqueFilters)
)
)
.limit(1)
const now = new Date()
if (!existingRow && table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
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 })
}
}

View File

@@ -1,293 +0,0 @@
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<WorkspaceAccessResult> {
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 })
}
}

View File

@@ -1,188 +0,0 @@
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<TableAccessCheck> {
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<TableAccessCheck> {
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<AccessResult> {
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<boolean> {
const table = await getTableById(tableId)
return table?.workspaceId === workspaceId
}
export function errorResponse(
message: string,
status: number,
details?: unknown
): NextResponse<ApiErrorResponse> {
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,
}
}

View File

@@ -5,11 +5,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -51,13 +47,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Mistral parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
userId,
})
let fileUrl = validatedData.filePath
if (isInternalFileUrl(validatedData.filePath)) {
if (validatedData.filePath?.includes('/api/files/serve/')) {
try {
const storageKey = extractStorageKey(validatedData.filePath)

View File

@@ -5,11 +5,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -52,13 +48,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Pulse parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
userId,
})
let fileUrl = validatedData.filePath
if (isInternalFileUrl(validatedData.filePath)) {
if (validatedData.filePath?.includes('/api/files/serve/')) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)

View File

@@ -5,11 +5,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -48,13 +44,13 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Reducto parse request`, {
filePath: validatedData.filePath,
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
userId,
})
let fileUrl = validatedData.filePath
if (isInternalFileUrl(validatedData.filePath)) {
if (validatedData.filePath?.includes('/api/files/serve/')) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)

View File

@@ -79,13 +79,11 @@ export async function POST(request: NextRequest) {
// Generate public URL for destination (properly encode the destination key)
const encodedDestKey = validatedData.destinationKey.split('/').map(encodeURIComponent).join('/')
const url = `https://${validatedData.destinationBucket}.s3.${validatedData.region}.amazonaws.com/${encodedDestKey}`
const uri = `s3://${validatedData.destinationBucket}/${validatedData.destinationKey}`
return NextResponse.json({
success: true,
output: {
url,
uri,
copySourceVersionId: result.CopySourceVersionId,
versionId: result.VersionId,
etag: result.CopyObjectResult?.ETag,

View File

@@ -117,13 +117,11 @@ export async function POST(request: NextRequest) {
const encodedKey = validatedData.objectKey.split('/').map(encodeURIComponent).join('/')
const url = `https://${validatedData.bucketName}.s3.${validatedData.region}.amazonaws.com/${encodedKey}`
const uri = `s3://${validatedData.bucketName}/${validatedData.objectKey}`
return NextResponse.json({
success: true,
output: {
url,
uri,
etag: result.ETag,
location: url,
key: validatedData.objectKey,

View File

@@ -1,637 +0,0 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
validateAwsRegion,
validateExternalUrl,
validateS3BucketName,
} from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
const logger = createLogger('TextractParseAPI')
const QuerySchema = z.object({
Text: z.string().min(1),
Alias: z.string().optional(),
Pages: z.array(z.string()).optional(),
})
const TextractParseSchema = z
.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS region is required'),
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
filePath: z.string().optional(),
s3Uri: z.string().optional(),
featureTypes: z
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
.optional(),
queries: z.array(QuerySchema).optional(),
})
.superRefine((data, ctx) => {
const regionValidation = validateAwsRegion(data.region, 'AWS region')
if (!regionValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: regionValidation.error,
path: ['region'],
})
}
})
function getSignatureKey(
key: string,
dateStamp: string,
regionName: string,
serviceName: string
): Buffer {
const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest()
const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest()
const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest()
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
return kSigning
}
function signAwsRequest(
method: string,
host: string,
uri: string,
body: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
service: string,
amzTarget: string
): Record<string, string> {
const date = new Date()
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
const canonicalHeaders =
`content-type:application/x-amz-json-1.1\n` +
`host:${host}\n` +
`x-amz-date:${amzDate}\n` +
`x-amz-target:${amzTarget}\n`
const signedHeaders = 'content-type;host;x-amz-date;x-amz-target'
const canonicalRequest = `${method}\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
const algorithm = 'AWS4-HMAC-SHA256'
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`
const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}`
const signingKey = getSignatureKey(secretAccessKey, dateStamp, region, service)
const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex')
const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
return {
'Content-Type': 'application/x-amz-json-1.1',
Host: host,
'X-Amz-Date': amzDate,
'X-Amz-Target': amzTarget,
Authorization: authorizationHeader,
}
}
async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch document: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
const bytes = Buffer.from(arrayBuffer).toString('base64')
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return { bytes, contentType }
}
function parseS3Uri(s3Uri: string): { bucket: string; key: string } {
const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw new Error(
`Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`
)
}
const bucket = match[1]
const key = match[2]
const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name')
if (!bucketValidation.isValid) {
throw new Error(bucketValidation.error)
}
if (key.includes('..') || key.startsWith('/')) {
throw new Error('S3 key contains invalid path traversal sequences')
}
return { bucket, key }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callTextractAsync(
host: string,
amzTarget: string,
body: Record<string, unknown>,
accessKeyId: string,
secretAccessKey: string,
region: string
): Promise<Record<string, unknown>> {
const bodyString = JSON.stringify(body)
const headers = signAwsRequest(
'POST',
host,
'/',
bodyString,
accessKeyId,
secretAccessKey,
region,
'textract',
amzTarget
)
const response = await fetch(`https://${host}/`, {
method: 'POST',
headers,
body: bodyString,
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `Textract API error: ${response.statusText}`
try {
const errorJson = JSON.parse(errorText)
if (errorJson.Message) {
errorMessage = errorJson.Message
} else if (errorJson.__type) {
errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}`
}
} catch {
// Use default error message
}
throw new Error(errorMessage)
}
return response.json()
}
async function pollForJobCompletion(
host: string,
jobId: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
useAnalyzeDocument: boolean,
requestId: string
): Promise<Record<string, unknown>> {
const pollIntervalMs = 5000 // 5 seconds between polls
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
const getTarget = useAnalyzeDocument
? 'Textract.GetDocumentAnalysis'
: 'Textract.GetDocumentTextDetection'
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await callTextractAsync(
host,
getTarget,
{ JobId: jobId },
accessKeyId,
secretAccessKey,
region
)
const jobStatus = result.JobStatus as string
if (jobStatus === 'SUCCEEDED') {
logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
if (jobStatus === 'FAILED') {
throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`)
}
if (jobStatus === 'PARTIAL_SUCCESS') {
logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`)
await sleep(pollIntervalMs)
}
throw new Error(
`Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`
)
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Unauthorized',
},
{ status: 401 }
)
}
const userId = authResult.userId
const body = await request.json()
const validatedData = TextractParseSchema.parse(body)
const processingMode = validatedData.processingMode || 'sync'
const featureTypes = validatedData.featureTypes ?? []
const useAnalyzeDocument = featureTypes.length > 0
const host = `textract.${validatedData.region}.amazonaws.com`
logger.info(`[${requestId}] Textract parse request`, {
processingMode,
filePath: validatedData.filePath?.substring(0, 50),
s3Uri: validatedData.s3Uri?.substring(0, 50),
featureTypes,
userId,
})
if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}
const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key })
const startTarget = useAnalyzeDocument
? 'Textract.StartDocumentAnalysis'
: 'Textract.StartDocumentTextDetection'
const startBody: Record<string, unknown> = {
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key,
},
},
}
if (useAnalyzeDocument) {
startBody.FeatureTypes = featureTypes
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
startBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
}
const startResult = await callTextractAsync(
host,
startTarget,
startBody,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region
)
const jobId = startResult.JobId as string
if (!jobId) {
throw new Error('Failed to start Textract job: No JobId returned')
}
logger.info(`[${requestId}] Async job started`, { jobId })
const textractData = await pollForJobCompletion(
host,
jobId,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
useAnalyzeDocument,
requestId
)
logger.info(`[${requestId}] Textract async parse successful`, {
pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
blockCount: (textractData.Blocks as unknown[])?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
},
modelVersion: (textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion) as string | undefined,
},
})
}
if (!validatedData.filePath) {
return NextResponse.json(
{
success: false,
error: 'File path is required for single-page processing',
},
{ status: 400 }
)
}
let fileUrl = validatedData.filePath
const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
if (isInternalFilePath) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return NextResponse.json(
{
success: false,
error: 'File not found',
},
{ status: 404 }
)
}
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
return NextResponse.json(
{
success: false,
error: 'Failed to generate file access URL',
},
{ status: 500 }
)
}
} else if (validatedData.filePath?.startsWith('/')) {
// Reject arbitrary absolute paths that don't contain /api/files/serve/
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: validatedData.filePath.substring(0, 50),
})
return NextResponse.json(
{
success: false,
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
{ status: 400 }
)
} else {
const urlValidation = validateExternalUrl(fileUrl, 'Document URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] SSRF attempt blocked`, {
userId,
url: fileUrl.substring(0, 100),
error: urlValidation.error,
})
return NextResponse.json(
{
success: false,
error: urlValidation.error,
},
{ status: 400 }
)
}
}
const { bytes, contentType } = await fetchDocumentBytes(fileUrl)
// Track if this is a PDF for better error messaging
const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
const uri = '/'
let textractBody: Record<string, unknown>
let amzTarget: string
if (useAnalyzeDocument) {
amzTarget = 'Textract.AnalyzeDocument'
textractBody = {
Document: {
Bytes: bytes,
},
FeatureTypes: featureTypes,
}
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
textractBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
} else {
amzTarget = 'Textract.DetectDocumentText'
textractBody = {
Document: {
Bytes: bytes,
},
}
}
const bodyString = JSON.stringify(textractBody)
const headers = signAwsRequest(
'POST',
host,
uri,
bodyString,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
'textract',
amzTarget
)
const textractResponse = await fetch(`https://${host}${uri}`, {
method: 'POST',
headers,
body: bodyString,
})
if (!textractResponse.ok) {
const errorText = await textractResponse.text()
logger.error(`[${requestId}] Textract API error:`, errorText)
let errorMessage = `Textract API error: ${textractResponse.statusText}`
let isUnsupportedFormat = false
try {
const errorJson = JSON.parse(errorText)
if (errorJson.Message) {
errorMessage = errorJson.Message
} else if (errorJson.__type) {
errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}`
}
// Check for unsupported document format error
isUnsupportedFormat =
errorJson.__type === 'UnsupportedDocumentException' ||
errorJson.Message?.toLowerCase().includes('unsupported document') ||
errorText.toLowerCase().includes('unsupported document')
} catch {
isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document')
}
// Provide helpful message for unsupported format (likely multi-page PDF)
if (isUnsupportedFormat && isPdf) {
errorMessage =
'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.'
}
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: textractResponse.status }
)
}
const textractData = await textractResponse.json()
logger.info(`[${requestId}] Textract parse successful`, {
pageCount: textractData.DocumentMetadata?.Pages ?? 0,
blockCount: textractData.Blocks?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: textractData.DocumentMetadata?.Pages ?? 0,
},
modelVersion:
textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion ??
undefined,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in Textract parse:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -11,7 +11,6 @@ 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'
@@ -61,7 +60,6 @@ interface RequestBody {
history?: ChatMessage[]
workflowId?: string
generationType?: string
wandContext?: Record<string, unknown>
}
function safeStringify(value: unknown): string {
@@ -72,38 +70,6 @@ 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<string, unknown>
) => Promise<string | null>
/**
* Registry of wand enrichers by generationType.
* Each enricher returns additional context to append to the system prompt.
*/
const wandEnrichers: Partial<Record<string, WandEnricher>> = {
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: {
@@ -193,15 +159,7 @@ export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as RequestBody
const {
prompt,
systemPrompt,
stream = false,
history = [],
workflowId,
generationType,
wandContext = {},
} = body
const { prompt, systemPrompt, stream = false, history = [], workflowId, generationType } = body
if (!prompt) {
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
@@ -269,15 +227,20 @@ export async function POST(req: NextRequest) {
systemPrompt ||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
// 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 === '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
}
if (generationType === 'json-object') {

View File

@@ -12,10 +12,6 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -29,7 +25,7 @@ import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
import { normalizeName } from '@/executor/constants'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
@@ -42,8 +38,6 @@ const ExecuteWorkflowSchema = z.object({
useDraftState: z.boolean().optional(),
input: z.any().optional(),
isClientSession: z.boolean().optional(),
includeFileBase64: z.boolean().optional().default(true),
base64MaxBytes: z.number().int().positive().optional(),
workflowStateOverride: z
.object({
blocks: z.record(z.any()),
@@ -220,8 +214,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState,
input: validatedInput,
isClientSession = false,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
} = validation.data
@@ -235,8 +227,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
triggerType,
stream,
useDraftState,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest
@@ -437,31 +427,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
snapshot,
callbacks: {},
loggingSession,
includeFileBase64,
base64MaxBytes,
})
const outputWithBase64 = includeFileBase64
? ((await hydrateUserFilesWithBase64(result.output, {
requestId,
executionId,
maxBytes: base64MaxBytes,
})) as NormalizedBlockOutput)
: result.output
const resultWithBase64 = { ...result, output: outputWithBase64 }
// Cleanup base64 cache for this execution
await cleanupExecutionBase64Cache(executionId)
const hasResponseBlock = workflowHasResponseBlock(resultWithBase64)
const hasResponseBlock = workflowHasResponseBlock(result)
if (hasResponseBlock) {
return createHttpResponseFromBlock(resultWithBase64)
return createHttpResponseFromBlock(result)
}
const filteredResult = {
success: result.success,
output: outputWithBase64,
output: result.output,
error: result.error,
metadata: result.metadata
? {
@@ -523,8 +498,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
includeFileBase64,
base64MaxBytes,
},
executionId,
})
@@ -725,8 +698,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
loggingSession,
abortSignal: abortController.signal,
includeFileBase64,
base64MaxBytes,
})
if (result.status === 'paused') {
@@ -779,21 +750,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
data: {
success: result.success,
output: includeFileBase64
? await hydrateUserFilesWithBase64(result.output, {
requestId,
executionId,
maxBytes: base64MaxBytes,
})
: result.output,
output: result.output,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
},
})
// Cleanup base64 cache for this execution
await cleanupExecutionBase64Cache(executionId)
} catch (error: any) {
const errorMessage = error.message || 'Unknown error'
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)

View File

@@ -2,7 +2,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { isUserFile } from '@/lib/core/utils/display-filters'
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
@@ -17,7 +17,7 @@ function extractFilesFromData(
return files
}
if (isUserFileWithMetadata(data)) {
if (isUserFile(data)) {
if (!seenIds.has(data.id)) {
seenIds.add(data.id)
files.push({
@@ -232,7 +232,7 @@ export function useChatStreaming() {
return null
}
if (isUserFileWithMetadata(value)) {
if (isUserFile(value)) {
return null
}
@@ -285,7 +285,7 @@ export function useChatStreaming() {
const value = getOutputValue(blockOutputs, config.path)
if (isUserFileWithMetadata(value)) {
if (isUserFile(value)) {
extractedFiles.push({
id: value.id,
name: value.name,

View File

@@ -555,7 +555,7 @@ export function DocumentTagsModal({
Cancel
</Button>
<Button
variant={canSaveTag ? 'tertiary' : 'default'}
variant='tertiary'
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}

View File

@@ -300,7 +300,7 @@ export function EditChunkModal({
</Button>
{userPermissions.canEdit && (
<Button
variant={hasUnsavedChanges ? 'tertiary' : 'default'}
variant='tertiary'
onClick={handleSaveContent}
type='button'
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}

View File

@@ -39,9 +39,6 @@ export function RenameDocumentModal({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check if name has changed from initial value
const hasChanges = name.trim() !== initialName.trim()
useEffect(() => {
if (open) {
setName(initialName)
@@ -126,11 +123,7 @@ export function RenameDocumentModal({
>
Cancel
</Button>
<Button
variant={hasChanges ? 'tertiary' : 'default'}
type='submit'
disabled={isSubmitting || !name?.trim() || !hasChanges}
>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge, Button, DocumentAttachment, Tooltip } from '@/components/emcn'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -143,7 +143,6 @@ export function BaseCard({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const searchParams = new URLSearchParams({
kbName: title,
@@ -152,23 +151,6 @@ export function BaseCard({
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (menuButtonRef.current) {
const rect = menuButtonRef.current.getBoundingClientRect()
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.bottom,
} as React.MouseEvent
handleContextMenu(syntheticEvent)
}
},
[handleContextMenu]
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isContextMenuOpen) {
@@ -241,24 +223,9 @@ export function BaseCard({
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{title}
</h3>
<div className='flex items-center gap-[4px]'>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
<Button
ref={menuButtonRef}
variant='ghost'
size='sm'
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
onClick={handleMenuButtonClick}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='3' cy='8' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='13' cy='8' r='1.5' />
</svg>
</Button>
</div>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
</div>
<div className='flex flex-1 flex-col gap-[8px]'>

View File

@@ -70,12 +70,6 @@ export function EditKnowledgeBaseModal({
})
const nameValue = watch('name')
const descriptionValue = watch('description')
// Check if form values have changed from initial values
const hasChanges =
nameValue?.trim() !== initialName.trim() ||
(descriptionValue?.trim() || '') !== (initialDescription?.trim() || '')
useEffect(() => {
if (open) {
@@ -165,9 +159,9 @@ export function EditKnowledgeBaseModal({
Cancel
</Button>
<Button
variant={hasChanges ? 'tertiary' : 'default'}
variant='tertiary'
type='submit'
disabled={isSubmitting || !nameValue?.trim() || !hasChanges}
disabled={isSubmitting || !nameValue?.trim()}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>

View File

@@ -265,11 +265,12 @@ export function Knowledge() {
</div>
</div>
) : error ? (
<div className='col-span-full flex h-64 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<span className='text-[13px]'>
Error: {typeof error === 'string' ? error : 'Failed to load knowledge bases'}
</span>
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Error loading knowledge bases
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
</div>
</div>
) : (

View File

@@ -234,7 +234,7 @@ function ProgressBar({
{segments.map((segment, index) => (
<div
key={index}
className='absolute h-full opacity-70'
className='absolute h-full'
style={{
left: `${segment.startPercent}%`,
width: `${segment.widthPercent}%`,

View File

@@ -257,7 +257,7 @@ export const LogDetails = memo(function LogDetails({
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-[6px] bg-[#bbf7d0] px-[9px] py-[2px] font-medium text-[#15803d] text-[12px] dark:bg-[#14291B] dark:text-[#86EFAC]'>
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</div>

View File

@@ -19,7 +19,6 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
@@ -212,12 +211,12 @@ export function LogsToolbar({
}, [level])
const statusOptions: ComboboxOption[] = useMemo(
() =>
(Object.keys(STATUS_CONFIG) as LogStatus[]).map((status) => ({
value: status,
label: STATUS_CONFIG[status].label,
icon: getColorIcon(STATUS_CONFIG[status].color),
})),
() => [
{ value: 'error', label: 'Error', icon: getColorIcon('var(--text-error)') },
{ value: 'info', label: 'Info', icon: getColorIcon('var(--terminal-status-info-color)') },
{ value: 'running', label: 'Running', icon: getColorIcon('#22c55e') },
{ value: 'pending', label: 'Pending', icon: getColorIcon('#f59e0b') },
],
[]
)
@@ -243,8 +242,12 @@ export function LogsToolbar({
const selectedStatusColor = useMemo(() => {
if (selectedStatuses.length !== 1) return null
const status = selectedStatuses[0] as LogStatus
return STATUS_CONFIG[status]?.color ?? null
const status = selectedStatuses[0]
if (status === 'error') return 'var(--text-error)'
if (status === 'info') return 'var(--terminal-status-info-color)'
if (status === 'running') return '#22c55e'
if (status === 'pending') return '#f59e0b'
return null
}, [selectedStatuses])
const workflowOptions: ComboboxOption[] = useMemo(

View File

@@ -5,6 +5,7 @@ import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
@@ -15,8 +16,10 @@ export const LOG_COLUMNS = {
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
@@ -27,6 +30,7 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/**
@@ -49,28 +53,30 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus {
}
}
export const STATUS_CONFIG: Record<
/** Configuration mapping log status to Badge variant and display label */
const STATUS_VARIANT_MAP: Record<
LogStatus,
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string; color: string }
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
> = {
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
running: { variant: 'green', label: 'Running', color: '#22c55e' },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
error: { variant: 'red', label: 'Error' },
pending: { variant: 'amber', label: 'Pending' },
running: { variant: 'green', label: 'Running' },
cancelled: { variant: 'gray', label: 'Cancelled' },
info: { variant: 'gray', label: 'Info' },
}
/** Configuration mapping core trigger types to Badge color variants */
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
manual: 'gray-secondary',
api: 'blue',
schedule: 'green',
chat: 'purple',
webhook: 'orange',
mcp: 'cyan',
a2a: 'teal',
}
interface StatusBadgeProps {
/** The execution status to display */
status: LogStatus
}
@@ -80,13 +86,14 @@ interface StatusBadgeProps {
* @returns A Badge with dot indicator and status label
*/
export const StatusBadge = React.memo(({ status }: StatusBadgeProps) => {
const config = STATUS_CONFIG[status]
const config = STATUS_VARIANT_MAP[status]
return React.createElement(Badge, { variant: config.variant, dot: true }, config.label)
})
StatusBadge.displayName = 'StatusBadge'
interface TriggerBadgeProps {
/** The trigger type identifier (e.g., 'manual', 'api', or integration block type) */
trigger: string
}

View File

@@ -1,72 +0,0 @@
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) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{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 (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
</TableRow>
))}
</>
)
}
interface EmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
<div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -1,99 +0,0 @@
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 <span className='text-[var(--text-muted)] italic'></span>
}
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'json')
}}
title='Click to view full JSON'
>
{jsonStr}
</button>
)
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
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 (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'date')
}}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'text')
}}
title='Click to view full text'
>
{strValue}
</button>
)
}
return <span className='text-[var(--text-primary)]'>{strValue}</span>
}

View File

@@ -1,84 +0,0 @@
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 (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{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',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -1,49 +0,0 @@
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 (
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={onEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,207 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Checkbox, Input, Textarea } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
interface EditableCellProps {
value: unknown
column: ColumnDefinition
onChange: (value: unknown) => void
isEditing?: boolean
isNew?: boolean
}
function formatValueForDisplay(value: unknown, type: string): string {
if (value === null || value === undefined) return 'NULL'
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value)
}
if (type === 'boolean') {
return value ? 'TRUE' : 'FALSE'
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return String(value)
}
}
return String(value)
}
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 EditableCell({
value,
column,
onChange,
isEditing = false,
isNew = false,
}: EditableCellProps) {
const [localValue, setLocalValue] = useState<unknown>(value)
const [isActive, setIsActive] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus()
}
}, [isActive])
const handleFocus = useCallback(() => {
setIsActive(true)
}, [])
const handleBlur = useCallback(() => {
setIsActive(false)
if (localValue !== value) {
onChange(localValue)
}
}, [localValue, value, onChange])
const handleChange = useCallback((newValue: unknown) => {
setLocalValue(newValue)
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && column.type !== 'json') {
e.preventDefault()
;(e.target as HTMLElement).blur()
}
if (e.key === 'Escape') {
setLocalValue(value)
;(e.target as HTMLElement).blur()
}
},
[value, column.type]
)
const isNull = value === null || value === undefined
// Boolean type - always show checkbox
if (column.type === 'boolean') {
return (
<div className='flex items-center'>
<Checkbox
size='sm'
checked={Boolean(localValue)}
onCheckedChange={(checked) => {
const newValue = checked === true
setLocalValue(newValue)
onChange(newValue)
}}
/>
<span
className={cn(
'ml-[8px] text-[12px]',
localValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'
)}
>
{localValue ? 'TRUE' : 'FALSE'}
</span>
</div>
)
}
// JSON type - use textarea
if (column.type === 'json') {
if (isActive || isNew) {
return (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className='h-[60px] min-w-[200px] resize-none font-mono text-[11px]'
placeholder='{"key": "value"}'
/>
)
}
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left font-mono text-[11px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}
// Active/editing state for other types
if (isActive || isNew) {
return (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn(
'h-[28px] min-w-[120px] text-[12px]',
column.type === 'number' && 'font-mono'
)}
placeholder={isNull ? 'NULL' : ''}
/>
)
}
// Display state
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left text-[13px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: 'text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}

View File

@@ -1,41 +0,0 @@
'use client'
import { X } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import type { TempRow } from '../hooks/use-inline-editing'
import { EditableCell } from './editable-cell'
interface EditableRowProps {
row: TempRow
columns: ColumnDefinition[]
onUpdateCell: (tempId: string, column: string, value: unknown) => void
onRemove: (tempId: string) => void
}
export function EditableRow({ row, columns, onUpdateCell, onRemove }: EditableRowProps) {
return (
<TableRow className='bg-amber-500/20 hover:bg-amber-500/30'>
<TableCell className='w-[40px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(row.tempId)}
className='h-[20px] w-[20px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<EditableCell
value={row.data[column.name]}
column={column}
onChange={(value) => onUpdateCell(row.tempId, column.name, value)}
isNew
/>
</TableCell>
))}
</TableRow>
)
}

View File

@@ -1,169 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter } 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'
type Column = Pick<ColumnDefinition, 'name' | 'type'>
interface FilterPanelProps {
columns: Column[]
isVisible: boolean
onApply: (options: QueryOptions) => void
onClose: () => void
isLoading?: boolean
}
// Operators that don't need a value input
const NO_VALUE_OPERATORS = ['is_null', 'is_not_null']
// Options for the first filter row
const WHERE_OPTIONS = [{ value: 'where', label: 'where' }]
export function FilterPanel({
columns,
isVisible,
onApply,
onClose,
isLoading = false,
}: FilterPanelProps) {
const [rules, setRules] = useState<FilterRule[]>([])
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const {
comparisonOptions,
logicalOptions,
addRule: handleAddRule,
removeRule: handleRemoveRule,
updateRule: handleUpdateRule,
} = useFilterBuilder({
columns: columnOptions,
rules,
setRules,
})
// Auto-add first filter when panel opens with no filters
useEffect(() => {
if (isVisible && rules.length === 0 && columns.length > 0) {
handleAddRule()
}
}, [isVisible, rules.length, columns.length, handleAddRule])
const handleApply = useCallback(() => {
const filter = filterRulesToFilter(rules)
onApply({ filter, sort: null })
}, [rules, onApply])
const handleClear = useCallback(() => {
setRules([])
onApply({ filter: null, sort: null })
onClose()
}, [onApply, onClose])
if (!isVisible) {
return null
}
return (
<div className='flex shrink-0 flex-col gap-2 border-[var(--border)] border-b px-4 py-3'>
{rules.map((rule, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator)
const isFirst = index === 0
return (
<div key={rule.id} className='flex items-center gap-2'>
{/* Remove button */}
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveRule(rule.id)}
aria-label='Remove filter'
className='shrink-0 p-1'
>
<X className='h-3.5 w-3.5' />
</Button>
{/* Where / And / Or */}
<div className='w-20 shrink-0'>
{isFirst ? (
<Combobox size='sm' options={WHERE_OPTIONS} value='where' onChange={() => {}} />
) : (
<Combobox
size='sm'
options={logicalOptions}
value={rule.logicalOperator}
onChange={(value) =>
handleUpdateRule(rule.id, 'logicalOperator', value as 'and' | 'or')
}
/>
)}
</div>
{/* Column */}
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={rule.column}
onChange={(value) => handleUpdateRule(rule.id, 'column', value)}
placeholder='Column'
/>
</div>
{/* Operator */}
<div className='w-[120px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={rule.operator}
onChange={(value) => handleUpdateRule(rule.id, 'operator', value)}
/>
</div>
{/* Value (only if operator needs it) */}
{needsValue && (
<Input
className='w-[160px] shrink-0'
value={rule.value}
onChange={(e) => handleUpdateRule(rule.id, 'value', e.target.value)}
placeholder='Enter a value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApply()
}
}}
/>
)}
{/* Actions - only on first row */}
{isFirst && (
<div className='ml-1 flex items-center gap-1'>
<Button variant='tertiary' size='sm' onClick={handleApply} disabled={isLoading}>
Apply
</Button>
<Button variant='ghost' size='sm' onClick={handleAddRule}>
<Plus className='h-3 w-3' />
Add filter
</Button>
<Button variant='ghost' size='sm' onClick={handleClear}>
Clear filters
</Button>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,11 +0,0 @@
export * from './body-states'
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './context-menu'
export * from './editable-cell'
export * from './editable-row'
export * from './filter-panel'
export * from './row-modal'
export * from './schema-modal'
export * from './table-toolbar'
export * from './table-viewer'

View File

@@ -1,399 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Badge,
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<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
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)
}
function isFieldEmpty(value: unknown, type: string): boolean {
if (value === null || value === undefined) return true
if (type === 'boolean') return false // booleans always have a value (true/false)
if (typeof value === 'string') return value.trim() === ''
return false
}
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<Record<string, unknown>>({})
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Check if all required fields are filled
const hasRequiredFields = useMemo(() => {
const requiredColumns = columns.filter((col) => col.required)
return requiredColumns.every((col) => !isFieldEmpty(rowData[col.name], col.type))
}, [columns, rowData])
// 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 (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalHeader>Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}</ModalHeader>
<ModalBody>
<ErrorMessage error={error} />
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{isSingleRow ? '1 row' : `${deleteCount} rows`}
</span>
? This will permanently remove the data.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const isAddMode = mode === 'add'
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>{isAddMode ? 'Add New Row' : 'Edit Row'}</ModalHeader>
<ModalBody className='max-h-[60vh] space-y-[12px] overflow-y-auto'>
<ErrorMessage error={error} />
<div className='flex flex-col gap-[8px]'>
{columns.map((column) => (
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleFormSubmit}
disabled={isSubmitting || !hasRequiredFields}
>
{isSubmitting
? isAddMode
? 'Adding...'
: 'Updating...'
: isAddMode
? 'Add Row'
: 'Update Row'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
function ErrorMessage({ error }: { error: string | null }) {
if (!error) return null
return (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)
}
interface ColumnFieldProps {
column: ColumnDefinition
value: unknown
onChange: (value: unknown) => void
}
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
const renderInput = () => {
if (column.type === 'boolean') {
return (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{value ? 'True' : 'False'}
</Label>
</div>
)
}
if (column.type === 'json') {
return (
<Textarea
id={column.name}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={3}
className='font-mono text-[12px]'
required={column.required}
/>
)
}
return (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
required={column.required}
/>
)
}
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
</span>
<Badge size='sm'>{column.type}</Badge>
{column.unique && (
<Badge size='sm' variant='gray-secondary'>
unique
</Badge>
)}
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
{renderInput()}
</div>
</div>
</div>
)
}

View File

@@ -1,79 +0,0 @@
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
interface SchemaModalProps {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
}
export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='sm'>
<ModalHeader>Table Schema</ModalHeader>
<ModalBody>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell>{column.name}</TableCell>
<TableCell>
<Badge variant='gray-secondary' size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='gray-secondary' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='gray-secondary' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onClose(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -1,194 +0,0 @@
'use client'
import {
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface TableToolbarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
showFilters: boolean
onToggleFilters: () => void
onAddRecord: () => void
selectedCount: number
onDeleteSelected: () => void
onClearSelection: () => void
hasPendingChanges: boolean
onSaveChanges: () => void
onDiscardChanges: () => void
isSaving: boolean
currentPage: number
totalPages: number
onPreviousPage: () => void
onNextPage: () => void
}
export function TableToolbar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
showFilters,
onToggleFilters,
onAddRecord,
selectedCount,
onDeleteSelected,
onClearSelection,
hasPendingChanges,
onSaveChanges,
onDiscardChanges,
isSaving,
currentPage,
totalPages,
onPreviousPage,
onNextPage,
}: TableToolbarProps) {
const hasSelection = selectedCount > 0
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-2)] px-[16px]'>
{/* Left section: Navigation and table info */}
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
</div>
{/* Center section: Main actions */}
<div className='flex items-center gap-[8px]'>
{/* Pagination controls */}
<div className='flex items-center gap-[2px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onPreviousPage}
disabled={currentPage === 0 || isLoading}
>
<ChevronLeft className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Previous page</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage >= totalPages - 1 || isLoading}
>
<ChevronRight className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Next page</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Filters toggle */}
<Button variant={showFilters ? 'secondary' : 'ghost'} size='sm' onClick={onToggleFilters}>
<Filter className='mr-[4px] h-[12px] w-[12px]' />
Filters
</Button>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Pending changes actions */}
{hasPendingChanges ? (
<>
<Button variant='tertiary' size='sm' onClick={onSaveChanges} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
<Button variant='ghost' size='sm' onClick={onDiscardChanges} disabled={isSaving}>
Discard changes
</Button>
</>
) : (
<>
{/* Add record */}
<Button variant='default' size='sm' onClick={onAddRecord}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add record
</Button>
{/* Delete selected */}
{hasSelection && (
<Button variant='destructive' size='sm' onClick={onDeleteSelected}>
<Trash2 className='mr-[4px] h-[12px] w-[12px]' />
Delete {selectedCount} {selectedCount === 1 ? 'record' : 'records'}
</Button>
)}
</>
)}
{/* Clear selection */}
{hasSelection && !hasPendingChanges && (
<Button variant='ghost' size='sm' onClick={onClearSelection}>
Clear selection
</Button>
)}
</div>
{/* Right section: Row count and utilities */}
<div className='flex items-center gap-[6px]'>
{isLoading ? (
<Skeleton className='h-[16px] w-[50px]' />
) : (
<span className='text-[13px] text-[var(--text-tertiary)]'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</span>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh} disabled={isLoading}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm'>
<MoreHorizontal className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-[160px]'>
<PopoverItem onClick={onShowSchema}>View Schema</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@@ -1,331 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useContextMenu, useInlineEditing, useRowSelection, useTableData } from '../hooks'
import type { CellViewerData, QueryOptions } from '../lib/types'
import { EmptyRows, LoadingRows } from './body-states'
import { CellViewerModal } from './cell-viewer-modal'
import { ContextMenu } from './context-menu'
import { EditableCell } from './editable-cell'
import { EditableRow } from './editable-row'
import { FilterPanel } from './filter-panel'
import { RowModal } from './row-modal'
import { SchemaModal } from './schema-modal'
import { TableToolbar } from './table-toolbar'
export function TableViewer() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showFilters, setShowFilters] = useState(false)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
})
const columns = tableData?.schema?.columns || []
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
const {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
} = useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess: refetchRows,
})
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
const isAllSelected = rows.length > 0 && selectedCount === rows.length
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
}, [router, workspaceId])
const handleShowSchema = useCallback(() => {
setShowSchemaModal(true)
}, [])
const handleToggleFilters = useCallback(() => {
setShowFilters((prev) => !prev)
}, [])
const handleApplyQueryOptions = useCallback(
(options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
refetchRows()
},
[refetchRows]
)
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
const handleContextMenuEdit = useCallback(() => {
// For inline editing, we don't need the modal anymore
// The cell becomes editable on click
closeContextMenu()
}, [closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
const handleCellClick = useCallback(
(columnName: string, value: unknown, type: CellViewerData['type']) => {
setCellViewer({ columnName, value, type })
},
[]
)
const handleRemoveNewRow = useCallback(
(tempId: string) => {
discardChanges()
},
[discardChanges]
)
const handlePreviousPage = useCallback(() => {
setCurrentPage((p) => Math.max(0, p - 1))
}, [])
const handleNextPage = useCallback(() => {
setCurrentPage((p) => Math.min(totalPages - 1, p + 1))
}, [totalPages])
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
</div>
)
}
if (!tableData) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col'>
<TableToolbar
tableName={tableData.name}
totalCount={totalCount}
isLoading={isLoadingRows}
onNavigateBack={handleNavigateBack}
onShowSchema={handleShowSchema}
onRefresh={refetchRows}
showFilters={showFilters}
onToggleFilters={handleToggleFilters}
onAddRecord={addNewRow}
selectedCount={selectedCount}
onDeleteSelected={handleDeleteSelected}
onClearSelection={clearSelection}
hasPendingChanges={hasPendingChanges}
onSaveChanges={saveChanges}
onDiscardChanges={discardChanges}
isSaving={isSaving}
currentPage={currentPage}
totalPages={totalPages}
onPreviousPage={handlePreviousPage}
onNextPage={handleNextPage}
/>
<FilterPanel
columns={columns}
isVisible={showFilters}
onApply={handleApplyQueryOptions}
onClose={() => setShowFilters(false)}
isLoading={isLoadingRows}
/>
<div className='flex-1 overflow-auto'>
<Table>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</TableHead>
{columns.map((column) => (
<TableHead key={column.name}>
<div className='flex items-center gap-[6px]'>
<span className='text-[12px]'>{column.name}</span>
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* New rows being added */}
{newRows.map((newRow) => (
<EditableRow
key={newRow.tempId}
row={newRow}
columns={columns}
onUpdateCell={updateNewRowCell}
onRemove={handleRemoveNewRow}
/>
))}
{/* Loading state */}
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 && newRows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={addNewRow}
/>
) : (
/* Existing rows with inline editing */
rows.map((row) => {
const rowChanges = pendingChanges.get(row.id)
const hasChanges = !!rowChanges
return (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]',
hasChanges && 'bg-amber-500/10'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => {
const currentValue = rowChanges?.[column.name] ?? row.data[column.name]
return (
<TableCell key={column.name}>
<EditableCell
value={currentValue}
column={column}
onChange={(value) => updateExistingRowCell(row.id, column.name, value)}
/>
</TableCell>
)
})}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* Delete confirmation modal */}
{deletingRows.length > 0 && (
<RowModal
mode='delete'
isOpen={true}
onClose={() => setDeletingRows([])}
table={tableData}
rowIds={deletingRows}
onSuccess={() => {
refetchRows()
setDeletingRows([])
clearSelection()
}}
/>
)}
<SchemaModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
/>
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
<ContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
onEdit={handleContextMenuEdit}
onDelete={handleContextMenuDelete}
/>
</div>
)
}

View File

@@ -1,4 +0,0 @@
export * from './use-context-menu'
export * from './use-inline-editing'
export * from './use-row-selection'
export * from './use-table-data'

View File

@@ -1,37 +0,0 @@
import { useCallback, useState } from 'react'
import type { TableRow } from '@/lib/table'
import type { ContextMenuState } from '../lib/types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
closeContextMenu: () => void
}
export function useContextMenu(): UseContextMenuReturn {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
return {
contextMenu,
handleRowContextMenu,
closeContextMenu,
}
}

View File

@@ -1,192 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { nanoid } from 'nanoid'
import type { ColumnDefinition } from '@/lib/table'
const logger = createLogger('useInlineEditing')
export interface TempRow {
tempId: string
data: Record<string, unknown>
isNew: true
}
interface UseInlineEditingProps {
workspaceId: string
tableId: string
columns: ColumnDefinition[]
onSuccess: () => void
}
interface UseInlineEditingReturn {
newRows: TempRow[]
pendingChanges: Map<string, Record<string, unknown>>
addNewRow: () => void
updateNewRowCell: (tempId: string, column: string, value: unknown) => void
updateExistingRowCell: (rowId: string, column: string, value: unknown) => void
saveChanges: () => Promise<void>
discardChanges: () => void
hasPendingChanges: boolean
isSaving: boolean
error: string | null
}
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = null
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' || value === null ? 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
}
export function useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess,
}: UseInlineEditingProps): UseInlineEditingReturn {
const [newRows, setNewRows] = useState<TempRow[]>([])
const [pendingChanges, setPendingChanges] = useState<Map<string, Record<string, unknown>>>(
new Map()
)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasPendingChanges = newRows.length > 0 || pendingChanges.size > 0
const addNewRow = useCallback(() => {
const newRow: TempRow = {
tempId: `temp-${nanoid()}`,
data: createInitialRowData(columns),
isNew: true,
}
setNewRows((prev) => [newRow, ...prev])
}, [columns])
const updateNewRowCell = useCallback((tempId: string, column: string, value: unknown) => {
setNewRows((prev) =>
prev.map((row) =>
row.tempId === tempId ? { ...row, data: { ...row.data, [column]: value } } : row
)
)
}, [])
const updateExistingRowCell = useCallback((rowId: string, column: string, value: unknown) => {
setPendingChanges((prev) => {
const newMap = new Map(prev)
const existing = newMap.get(rowId) || {}
newMap.set(rowId, { ...existing, [column]: value })
return newMap
})
}, [])
const saveChanges = useCallback(async () => {
setIsSaving(true)
setError(null)
try {
// Save new rows
for (const newRow of newRows) {
const cleanData = cleanRowData(columns, newRow.data)
const res = await fetch(`/api/table/${tableId}/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')
}
}
// Save edited rows
for (const [rowId, changes] of pendingChanges.entries()) {
const cleanData = cleanRowData(columns, changes)
const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, {
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')
}
}
// Clear state and refresh
setNewRows([])
setPendingChanges(new Map())
onSuccess()
logger.info('Changes saved successfully')
} catch (err) {
logger.error('Failed to save changes:', err)
setError(err instanceof Error ? err.message : 'Failed to save changes')
} finally {
setIsSaving(false)
}
}, [newRows, pendingChanges, columns, tableId, workspaceId, onSuccess])
const discardChanges = useCallback(() => {
setNewRows([])
setPendingChanges(new Map())
setError(null)
}, [])
return {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
error,
}
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { TableRow } from '@/lib/table'
interface UseRowSelectionReturn {
selectedRows: Set<string>
handleSelectAll: () => void
handleSelectRow: (rowId: string) => void
clearSelection: () => void
}
export function useRowSelection(rows: TableRow[]): UseRowSelectionReturn {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
useEffect(() => {
setSelectedRows((prev) => {
if (prev.size === 0) return prev
const currentRowIds = new Set(rows.map((r) => r.id))
const filtered = new Set([...prev].filter((id) => currentRowIds.has(id)))
// Only update state if something was actually filtered out
return filtered.size !== prev.size ? filtered : prev
})
}, [rows])
const handleSelectAll = useCallback(() => {
if (selectedRows.size === rows.length) {
setSelectedRows(new Set())
} else {
setSelectedRows(new Set(rows.map((r) => r.id)))
}
}, [rows, selectedRows.size])
const handleSelectRow = useCallback((rowId: string) => {
setSelectedRows((prev) => {
const newSet = new Set(prev)
if (newSet.has(rowId)) {
newSet.delete(rowId)
} else {
newSet.add(rowId)
}
return newSet
})
}, [])
const clearSelection = useCallback(() => {
setSelectedRows(new Set())
}, [])
return {
selectedRows,
handleSelectAll,
handleSelectRow,
clearSelection,
}
}

View File

@@ -1,86 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import type { TableDefinition, TableRow } from '@/lib/table'
import { ROWS_PER_PAGE } from '../lib/constants'
import type { QueryOptions } from '../lib/types'
interface UseTableDataParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
currentPage: number
}
interface UseTableDataReturn {
tableData: TableDefinition | undefined
isLoadingTable: boolean
rows: TableRow[]
totalCount: number
totalPages: number
isLoadingRows: boolean
refetchRows: () => void
}
export function useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
}: UseTableDataParams): UseTableDataReturn {
const { data: tableData, isLoading: isLoadingTable } = useQuery({
queryKey: ['table', tableId],
queryFn: async () => {
const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
if (!res.ok) throw new Error('Failed to fetch table')
const json: { data?: { table: TableDefinition }; table?: TableDefinition } = await res.json()
const data = json.data || json
return (data as { table: TableDefinition }).table
},
})
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useQuery({
queryKey: ['table-rows', tableId, queryOptions, currentPage],
queryFn: async () => {
const searchParams = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (queryOptions.filter) {
searchParams.set('filter', JSON.stringify(queryOptions.filter))
}
if (queryOptions.sort) {
searchParams.set('sort', JSON.stringify(queryOptions.sort))
}
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch rows')
const json: {
data?: { rows: TableRow[]; totalCount: number }
rows?: TableRow[]
totalCount?: number
} = await res.json()
return json.data || json
},
enabled: !!tableData,
})
const rows = (rowsData?.rows || []) as TableRow[]
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
return {
tableData,
isLoadingTable,
rows,
totalCount,
totalPages,
isLoadingRows,
refetchRows,
}
}

View File

@@ -1,2 +0,0 @@
export const ROWS_PER_PAGE = 100
export const STRING_TRUNCATE_LENGTH = 50

View File

@@ -1,3 +0,0 @@
export * from './constants'
export * from './types'
export * from './utils'

View File

@@ -1,27 +0,0 @@
import type { Filter, Sort, TableRow } from '@/lib/table'
/**
* Query options for filtering and sorting table data
*/
export interface QueryOptions {
filter: Filter | null
sort: Sort | null
}
/**
* Data for viewing a cell's full content in a modal
*/
export interface CellViewerData {
columnName: string
value: unknown
type: 'json' | 'text' | 'date' | 'boolean' | 'number'
}
/**
* State for the row context menu (right-click)
*/
export interface ContextMenuState {
isOpen: boolean
position: { x: number; y: number }
row: TableRow | null
}

View File

@@ -1,21 +0,0 @@
type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray'
/**
* Returns the appropriate badge color variant for a column type
*/
export function getTypeBadgeVariant(type: string): BadgeVariant {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}

View File

@@ -1,5 +0,0 @@
import { TableViewer } from './components'
export default function TablePage() {
return <TableViewer />
}

View File

@@ -1,14 +0,0 @@
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'rowCount' | 'columnCount'
export type SortOrder = 'asc' | 'desc'
export const SORT_OPTIONS = [
{ value: 'updatedAt-desc', label: 'Last Updated' },
{ value: 'createdAt-desc', label: 'Newest First' },
{ value: 'createdAt-asc', label: 'Oldest First' },
{ value: 'name-asc', label: 'Name (A-Z)' },
{ value: 'name-desc', label: 'Name (Z-A)' },
{ value: 'rowCount-desc', label: 'Most Rows' },
{ value: 'rowCount-asc', label: 'Least Rows' },
{ value: 'columnCount-desc', label: 'Most Columns' },
{ value: 'columnCount-asc', label: 'Least Columns' },
] as const

View File

@@ -1,349 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import type { ColumnDefinition } from '@/lib/table'
import { useCreateTable } from '@/hooks/queries/use-tables'
const logger = createLogger('CreateModal')
interface CreateModalProps {
isOpen: boolean
onClose: () => void
}
const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'date', label: 'Date' },
{ value: 'json', label: 'JSON' },
]
interface ColumnWithId extends ColumnDefinition {
id: string
}
function createEmptyColumn(): ColumnWithId {
return { id: nanoid(), name: '', type: 'string', required: true, unique: false }
}
export function CreateModal({ isOpen, onClose }: CreateModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [tableName, setTableName] = useState('')
const [description, setDescription] = useState('')
const [columns, setColumns] = useState<ColumnWithId[]>([createEmptyColumn()])
const [error, setError] = useState<string | null>(null)
const createTable = useCreateTable(workspaceId)
// Form validation
const validColumns = useMemo(() => columns.filter((col) => col.name.trim()), [columns])
const duplicateColumnNames = useMemo(() => {
const names = validColumns.map((col) => col.name.toLowerCase())
const seen = new Set<string>()
const duplicates = new Set<string>()
names.forEach((name) => {
if (seen.has(name)) {
duplicates.add(name)
}
seen.add(name)
})
return duplicates
}, [validColumns])
const isFormValid = useMemo(() => {
const hasTableName = tableName.trim().length > 0
const hasAtLeastOneColumn = validColumns.length > 0
const hasNoDuplicates = duplicateColumnNames.size === 0
return hasTableName && hasAtLeastOneColumn && hasNoDuplicates
}, [tableName, validColumns.length, duplicateColumnNames.size])
const handleAddColumn = () => {
setColumns([...columns, createEmptyColumn()])
}
const handleRemoveColumn = (columnId: string) => {
if (columns.length > 1) {
setColumns(columns.filter((col) => col.id !== columnId))
}
}
const handleColumnChange = (
columnId: string,
field: keyof ColumnDefinition,
value: string | boolean
) => {
setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col)))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!tableName.trim()) {
setError('Table name is required')
return
}
// Validate column names
const validColumns = columns.filter((col) => col.name.trim())
if (validColumns.length === 0) {
setError('At least one column is required')
return
}
// Check for duplicate column names
const columnNames = validColumns.map((col) => col.name.toLowerCase())
const uniqueNames = new Set(columnNames)
if (uniqueNames.size !== columnNames.length) {
setError('Duplicate column names found')
return
}
// Strip internal IDs before sending to API
const columnsForApi = validColumns.map(({ id: _id, ...col }) => col)
try {
await createTable.mutateAsync({
name: tableName,
description: description || undefined,
schema: {
columns: columnsForApi,
},
})
// Reset form
resetForm()
onClose()
} catch (err) {
logger.error('Failed to create table:', err)
setError(err instanceof Error ? err.message : 'Failed to create table')
}
}
const resetForm = () => {
setTableName('')
setDescription('')
setColumns([createEmptyColumn()])
setError(null)
}
const handleClose = () => {
resetForm()
onClose()
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent size='lg'>
<ModalHeader>Create New Table</ModalHeader>
<ModalBody className='max-h-[70vh] overflow-y-auto'>
<form onSubmit={handleSubmit} className='space-y-[12px]'>
{error && (
<div className='rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
{error}
</div>
)}
{/* Table Name */}
<div>
<Label
htmlFor='tableName'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Table Name
</Label>
<Input
id='tableName'
value={tableName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTableName(e.target.value)}
placeholder='e.g., customer_orders'
className='h-9'
/>
</div>
{/* Description */}
<div>
<Label
htmlFor='description'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Description
</Label>
<Textarea
id='description'
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setDescription(e.target.value)
}
placeholder='Optional description for this table'
rows={2}
className='resize-none'
/>
</div>
{/* Columns */}
<div>
<div className='mb-[6.5px] flex items-center justify-between pl-[2px]'>
<Label className='font-medium text-[13px] text-[var(--text-primary)]'>
Columns
</Label>
<Button type='button' size='sm' variant='default' onClick={handleAddColumn}>
<Plus className='mr-1 h-3.5 w-3.5' />
Add
</Button>
</div>
{/* Column Headers */}
<div className='mb-2 flex items-center gap-[10px] text-[11px] text-[var(--text-secondary)]'>
<div className='flex-1 pl-3'>Name</div>
<div className='w-[110px] pl-3'>Type</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-[70px] cursor-help text-center'>Required</div>
</Tooltip.Trigger>
<Tooltip.Content>Field must have a value</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-[70px] cursor-help text-center'>Unique</div>
</Tooltip.Trigger>
<Tooltip.Content>No duplicate values allowed</Tooltip.Content>
</Tooltip.Root>
<div className='w-9' />
</div>
{/* Column Rows */}
<div className='flex flex-col gap-2'>
{columns.map((column) => (
<ColumnRow
key={column.id}
column={column}
isRemovable={columns.length > 1}
isDuplicate={duplicateColumnNames.has(column.name.toLowerCase())}
onChange={handleColumnChange}
onRemove={handleRemoveColumn}
/>
))}
</div>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Mark columns as unique to prevent duplicate values (e.g., id, email)
</p>
</div>
</form>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={createTable.isPending}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSubmit}
disabled={createTable.isPending || !isFormValid}
>
{createTable.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
interface ColumnRowProps {
column: ColumnWithId
isRemovable: boolean
isDuplicate: boolean
onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
onRemove: (columnId: string) => void
}
function ColumnRow({ column, isRemovable, isDuplicate, onChange, onRemove }: ColumnRowProps) {
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-[10px]'>
{/* Column Name */}
<div className='flex-1'>
<Input
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(column.id, 'name', e.target.value)
}
placeholder='column_name'
className={`h-9 ${isDuplicate ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
</div>
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-9'
/>
</div>
{/* Required Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
/>
</div>
{/* Unique Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
/>
</div>
{/* Delete Button */}
<div className='w-9'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => onRemove(column.id)}
disabled={!isRemovable}
className='h-9 w-9 p-0'
>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Remove column</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{isDuplicate && <p className='mt-1 pl-1 text-destructive text-sm'>Duplicate column name</p>}
</div>
)
}

View File

@@ -1,20 +0,0 @@
interface EmptyStateProps {
hasSearchQuery: boolean
}
export function EmptyState({ hasSearchQuery }: EmptyStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{hasSearchQuery ? 'No tables found' : 'No tables yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{hasSearchQuery
? 'Try adjusting your search query'
: 'Create your first table to store structured data for your workflows'}
</p>
</div>
</div>
)
}

View File

@@ -1,15 +0,0 @@
interface ErrorStateProps {
error: unknown
}
export function ErrorState({ error }: ErrorStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<span className='text-[13px]'>
Error: {error instanceof Error ? error.message : 'Failed to load tables'}
</span>
</div>
</div>
)
}

View File

@@ -1,8 +0,0 @@
export * from './create-modal'
export * from './empty-state'
export * from './error-state'
export * from './loading-state'
export * from './table-card'
export * from './table-card-context-menu'
export * from './table-list-context-menu'
export * from './tables-view'

View File

@@ -1,31 +0,0 @@
export function LoadingState() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className='flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] dark:bg-[var(--surface-4)]'
>
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-[15px] w-[60px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
</div>
</div>
))}
</>
)
}

View File

@@ -1,152 +0,0 @@
'use client'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface TableCardContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when open in new tab is clicked
*/
onOpenInNewTab?: () => void
/**
* Callback when view schema is clicked
*/
onViewSchema?: () => void
/**
* Callback when copy ID is clicked
*/
onCopyId?: () => void
/**
* Callback when delete is clicked
*/
onDelete?: () => void
/**
* Whether to show the open in new tab option
* @default true
*/
showOpenInNewTab?: boolean
/**
* Whether to show the view schema option
* @default true
*/
showViewSchema?: boolean
/**
* Whether to show the delete option
* @default true
*/
showDelete?: boolean
/**
* Whether the delete option is disabled
* @default false
*/
disableDelete?: boolean
}
/**
* Context menu component for table cards.
* Displays open in new tab, view schema, copy ID, and delete options in a popover at the right-click position.
*/
export function TableCardContextMenu({
isOpen,
position,
menuRef,
onClose,
onOpenInNewTab,
onViewSchema,
onCopyId,
onDelete,
showOpenInNewTab = true,
showViewSchema = true,
showDelete = true,
disableDelete = false,
}: TableCardContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
onClose()
}}
>
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* View and copy actions */}
{showViewSchema && onViewSchema && (
<PopoverItem
onClick={() => {
onViewSchema()
onClose()
}}
>
View schema
</PopoverItem>
)}
{onCopyId && (
<PopoverItem
onClick={() => {
onCopyId()
onClose()
}}
>
Copy ID
</PopoverItem>
)}
{((showViewSchema && onViewSchema) || onCopyId) && <PopoverDivider />}
{/* Destructive action */}
{showDelete && onDelete && (
<PopoverItem
disabled={disableDelete}
onClick={() => {
onDelete()
onClose()
}}
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,233 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Columns, Rows3 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import type { TableDefinition } from '@/lib/table'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteTable } from '@/hooks/queries/use-tables'
import { formatAbsoluteDate, formatRelativeTime } from '../lib/utils'
import { TableCardContextMenu } from './table-card-context-menu'
const logger = createLogger('TableCard')
interface TableCardProps {
table: TableDefinition
workspaceId: string
}
export function TableCard({ table, workspaceId }: TableCardProps) {
const router = useRouter()
const userPermissions = useUserPermissionsContext()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
menuRef,
handleContextMenu,
closeMenu: closeContextMenu,
} = useContextMenu()
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (menuButtonRef.current) {
const rect = menuButtonRef.current.getBoundingClientRect()
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.bottom,
} as React.MouseEvent
handleContextMenu(syntheticEvent)
}
},
[handleContextMenu]
)
const deleteTable = useDeleteTable(workspaceId)
const handleDelete = async () => {
try {
await deleteTable.mutateAsync(table.id)
setIsDeleteDialogOpen(false)
} catch (error) {
logger.error('Failed to delete table:', error)
}
}
const navigateToTable = () => {
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
}
const href = `/workspace/${workspaceId}/tables/${table.id}`
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isContextMenuOpen) {
e.preventDefault()
return
}
navigateToTable()
},
[isContextMenuOpen, navigateToTable]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigateToTable()
}
},
[navigateToTable]
)
const handleOpenInNewTab = useCallback(() => {
window.open(href, '_blank')
}, [href])
const handleViewSchema = useCallback(() => {
setIsSchemaModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
navigator.clipboard.writeText(table.id)
}, [table.id])
const handleDeleteFromContextMenu = useCallback(() => {
setIsDeleteDialogOpen(true)
}, [])
const columnCount = table.schema.columns.length
const shortId = `tb-${table.id.slice(0, 8)}`
return (
<>
<div
role='button'
tabIndex={0}
data-table-card
className='h-full cursor-pointer'
onClick={handleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
>
<div className='group flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:bg-[var(--surface-5)]'>
<div className='flex items-center justify-between gap-[8px]'>
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{table.name}
</h3>
<div className='flex items-center gap-[4px]'>
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
<Button
ref={menuButtonRef}
variant='ghost'
size='sm'
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
onClick={handleMenuButtonClick}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='3' cy='8' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='13' cy='8' r='1.5' />
</svg>
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px] text-[12px] text-[var(--text-tertiary)]'>
<span className='flex items-center gap-[4px]'>
<Columns className='h-[12px] w-[12px]' />
{columnCount} {columnCount === 1 ? 'col' : 'cols'}
</span>
<span className='flex items-center gap-[4px]'>
<Rows3 className='h-[12px] w-[12px]' />
{table.rowCount} {table.rowCount === 1 ? 'row' : 'rows'}
</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='text-[12px] text-[var(--text-tertiary)]'>
last updated: {formatRelativeTime(table.updatedAt)}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{formatAbsoluteDate(table.updatedAt)}</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
{table.description || 'No description'}
</p>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Delete Table</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{table.name}</span>? This
will permanently delete all {table.rowCount} rows.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setIsDeleteDialogOpen(false)}
disabled={deleteTable.isPending}
>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={deleteTable.isPending}>
{deleteTable.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Schema Viewer Modal */}
<SchemaModal
isOpen={isSchemaModalOpen}
onClose={() => setIsSchemaModalOpen(false)}
columns={table.schema.columns}
/>
<TableCardContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={menuRef}
onClose={closeContextMenu}
onOpenInNewTab={handleOpenInNewTab}
onViewSchema={handleViewSchema}
onCopyId={handleCopyId}
onDelete={handleDeleteFromContextMenu}
disableDelete={userPermissions.canEdit !== true}
/>
</>
)
}

View File

@@ -1,76 +0,0 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface TableListContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when add table is clicked
*/
onAddTable?: () => void
/**
* Whether the add option is disabled
* @default false
*/
disableAdd?: boolean
}
/**
* Context menu component for the tables list page.
* Displays "Add table" option when right-clicking on empty space.
*/
export function TableListContextMenu({
isOpen,
position,
menuRef,
onClose,
onAddTable,
disableAdd = false,
}: TableListContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{onAddTable && (
<PopoverItem
disabled={disableAdd}
onClick={() => {
onAddTable()
onClose()
}}
>
Add table
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,197 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ChevronDown, Database, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useTablesList } from '@/hooks/queries/use-tables'
import { useDebounce } from '@/hooks/use-debounce'
import { filterTables, sortTables } from '../lib/utils'
import { SORT_OPTIONS, type SortOption, type SortOrder } from './constants'
import { CreateModal } from './create-modal'
import { EmptyState } from './empty-state'
import { ErrorState } from './error-state'
import { LoadingState } from './loading-state'
import { TableCard } from './table-card'
import { TableListContextMenu } from './table-list-context-menu'
export function TablesView() {
const params = useParams()
const workspaceId = params.workspaceId as string
const userPermissions = useUserPermissionsContext()
const { data: tables = [], isLoading, error } = useTablesList(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
/**
* Handle context menu on the content area - only show menu when clicking on empty space
*/
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
const isOnCard = target.closest('[data-table-card]')
const isOnInteractive = target.closest('button, input, a, [role="button"]')
if (!isOnCard && !isOnInteractive) {
handleListContextMenu(e)
}
},
[handleListContextMenu]
)
/**
* Handle add table from context menu
*/
const handleAddTable = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
const currentSortValue = `${sortBy}-${sortOrder}`
const currentSortLabel =
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
/**
* Handles sort option change from dropdown
*/
const handleSortChange = (value: string) => {
const [field, order] = value.split('-') as [SortOption, SortOrder]
setSortBy(field)
setSortOrder(order)
setIsSortPopoverOpen(false)
}
/**
* Filter and sort tables based on search query and sort options
*/
const filteredAndSortedTables = useMemo(() => {
const filtered = filterTables(tables, debouncedSearchQuery)
return sortTables(filtered, sortBy, sortOrder)
}, [tables, debouncedSearchQuery, sortBy, sortOrder])
return (
<>
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div
className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'
onContextMenu={handleContentContextMenu}
>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#64748B] bg-[#F1F5F9] dark:border-[#334155] dark:bg-[#0F172A]'>
<Database className='h-[14px] w-[14px] text-[#64748B] dark:text-[#CBD5E1]' />
</div>
<h1 className='font-medium text-[18px]'>Tables</h1>
</div>
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
Create and manage data tables for your workflows.
</p>
</div>
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
{tables.length > 0 && (
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
{SORT_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={currentSortValue === option.value}
onClick={() => handleSortChange(option.value)}
>
{option.label}
</PopoverItem>
))}
</div>
</PopoverContent>
</Popover>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Create
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to create tables</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
<LoadingState />
) : filteredAndSortedTables.length === 0 ? (
<EmptyState hasSearchQuery={!!debouncedSearchQuery} />
) : error ? (
<ErrorState error={error} />
) : (
filteredAndSortedTables.map((table) => (
<TableCard key={table.id} table={table} workspaceId={workspaceId} />
))
)}
</div>
</div>
</div>
</div>
<CreateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
<TableListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
onAddTable={handleAddTable}
disableAdd={userPermissions.canEdit !== true}
/>
</>
)
}

View File

@@ -1,7 +0,0 @@
export default function TablesLayout({ children }: { children: React.ReactNode }) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
{children}
</div>
)
}

View File

@@ -1 +0,0 @@
export * from './utils'

View File

@@ -1,83 +0,0 @@
import type { TableDefinition } from '@/lib/table'
import type { SortOption, SortOrder } from '../components/constants'
/**
* Sort tables by the specified field and order
*/
export function sortTables(
tables: TableDefinition[],
sortBy: SortOption,
sortOrder: SortOrder
): TableDefinition[] {
return [...tables].sort((a, b) => {
let comparison = 0
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'createdAt':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'rowCount':
comparison = a.rowCount - b.rowCount
break
case 'columnCount':
comparison = a.schema.columns.length - b.schema.columns.length
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
/**
* Filter tables by search query
*/
export function filterTables(tables: TableDefinition[], searchQuery: string): TableDefinition[] {
if (!searchQuery.trim()) {
return tables
}
const query = searchQuery.toLowerCase()
return tables.filter(
(table) =>
table.name.toLowerCase().includes(query) || table.description?.toLowerCase().includes(query)
)
}
/**
* Formats a date as relative time (e.g., "5m ago", "2d ago")
*/
export function formatRelativeTime(dateValue: string | Date): string {
const dateString = typeof dateValue === 'string' ? dateValue : dateValue.toISOString()
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago`
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago`
return `${Math.floor(diffInSeconds / 31536000)}y ago`
}
/**
* Formats a date as absolute date string (e.g., "Jan 15, 2024, 10:30 AM")
*/
export function formatAbsoluteDate(dateValue: string | Date): string {
const dateString = typeof dateValue === 'string' ? dateValue : dateValue.toISOString()
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}

View File

@@ -1,26 +0,0 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { TablesView } from './components'
interface TablesPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function TablesPage({ params }: TablesPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <TablesView />
}

View File

@@ -212,10 +212,8 @@ export default function Templates({
) : filteredTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{emptyState.title}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{emptyState.description}</p>
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
</div>
</div>
) : (

View File

@@ -2,12 +2,12 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
@@ -48,38 +48,29 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const addNotification = useNotificationStore((s) => s.addNotification)
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const handleDuplicateBlock = useCallback(() => {
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState()
const existingBlocks = useWorkflowStore.getState().blocks
copyBlocks([blockId])
const blocks = useWorkflowStore.getState().blocks
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET)
if (!pasteData) return
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const blocks = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
newId,
newName,
positionOffset: DEFAULT_DUPLICATE_OFFSET,
subBlockValues,
})
setPendingSelection(blocks.map((b) => b.id))
collaborativeBatchAddBlocks(
blocks,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
setPendingSelection([newId])
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
@@ -99,7 +90,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = isInputDefinitionTrigger(blockType)
const isStartBlock = isValidStartBlockType(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
@@ -151,7 +142,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && (
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -222,29 +213,6 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -8,7 +8,7 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
/**
* Block information for context menu actions
@@ -74,16 +74,12 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasSingletonBlock = selectedBlocks.some(
(b) =>
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
)
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
@@ -131,7 +127,7 @@ export function BlockMenu({
<span>Paste</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
{!hasSingletonBlock && (
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {

View File

@@ -26,6 +26,7 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
@@ -54,12 +55,15 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onInvite,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
disableAdmin = false,
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -175,6 +179,22 @@ export function CanvasMenu({
>
{isChatOpen ? 'Close Chat' : 'Open Chat'}
</PopoverItem>
{/* Admin action - hidden when invitations are disabled */}
{!isInvitationsDisabled && (
<>
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
)

View File

@@ -886,16 +886,17 @@ export function Chat() {
onMouseDown={(e) => e.stopPropagation()}
>
{shouldShowConfigureStartInputsButton && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-[2px] font-medium font-sans text-[12px] text-[var(--text-primary)] hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()
handleConfigureStartInputs()
}}
>
<span className='whitespace-nowrap'>Add inputs</span>
</div>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
)}
<OutputSelect

View File

@@ -129,6 +129,10 @@ export function OutputSelect({
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
const operationValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value
: subBlockValues?.[block.id]?.operation
let outputsToProcess: Record<string, unknown> = {}
@@ -142,20 +146,10 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {}
}
} else {
// Build subBlocks object for tool selector
const rawSubBlockValues =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks
: subBlockValues?.[block.id]
const subBlocks: Record<string, { value: unknown }> = {}
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
for (const [key, val] of Object.entries(rawSubBlockValues)) {
// Handle both { value: ... } and raw value formats
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
}
}
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {}
const toolOutputs =
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
}

View File

@@ -138,24 +138,18 @@ export const Notifications = memo(function Notifications() {
}`}
>
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
<div className='flex items-start gap-[8px]'>
<div
className={`min-w-0 flex-1 font-medium text-[12px] leading-[16px] ${
hasAction ? 'line-clamp-2' : 'line-clamp-4'
}`}
>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
<div
className={`font-medium text-[12px] leading-[16px] ${
hasAction ? 'line-clamp-2' : 'line-clamp-4'
}`}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 shrink-0'
className='!p-1.5 -m-1.5 float-right ml-[16px]'
>
<X className='h-3 w-3' />
</Button>
@@ -164,6 +158,10 @@ export const Notifications = memo(function Notifications() {
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
{hasAction && (
<Button

View File

@@ -1510,7 +1510,8 @@ export function ToolCall({
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
toolCall.name === 'plan_respond' ||
toolCall.name === 'deploy_respond'
)
return null

Some files were not shown because too many files have changed in this diff Show More