Compare commits

..

1 Commits

Author SHA1 Message Date
Adam Gough
6f450c4f8e added while subflow without execution logic 2025-08-29 19:54:04 -07:00
359 changed files with 6704 additions and 38973 deletions

View File

@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
on:
push:
branches: [main, staging]
branches: [main]
tags: ['v*']
jobs:
build-and-push:
@@ -55,7 +56,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -69,7 +70,10 @@ jobs:
images: ${{ matrix.image }}
tags: |
type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=staging-${{ github.sha }}-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }}
type=ref,event=pr,suffix=-${{ matrix.arch }}
type=semver,pattern={{version}},suffix=-${{ matrix.arch }}
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch }}
type=semver,pattern={{major}}.{{minor}}.{{patch}},suffix=-${{ matrix.arch }}
type=sha,format=long,suffix=-${{ matrix.arch }}
- name: Build and push Docker image
@@ -78,7 +82,7 @@ jobs:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-v3
@@ -89,7 +93,7 @@ jobs:
create-manifests:
runs-on: ubuntu-latest
needs: build-and-push
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: github.event_name != 'pull_request'
strategy:
matrix:
include:
@@ -115,6 +119,10 @@ jobs:
images: ${{ matrix.image }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=sha,format=long
- name: Create and push manifest

View File

@@ -33,16 +33,12 @@
"microsoft_planner",
"microsoft_teams",
"mistral_parse",
"mongodb",
"mysql",
"notion",
"onedrive",
"openai",
"outlook",
"parallel_ai",
"perplexity",
"pinecone",
"postgresql",
"qdrant",
"reddit",
"s3",

View File

@@ -1,264 +0,0 @@
---
title: MongoDB
description: Connect to MongoDB database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mongodb"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='currentColor'
d='M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#45A538'
d='M61.729 110.054c-1.69-1.453-3.439-2.842-5.059-4.37-8.717-8.222-15.093-17.899-18.233-29.566-.865-3.211-1.442-6.474-1.627-9.792-.13-2.322-.318-4.665-.154-6.975.437-6.144 1.325-12.229 3.127-18.147l.099-.138c.175.233.427.439.516.702 1.759 5.18 3.505 10.364 5.242 15.551 5.458 16.3 10.909 32.604 16.376 48.9.107.318.384.579.583.866l-.87 2.969z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#46A037'
d='M88.038 42.812c-1.384.206-2.768.403-4.149.616-3.131.485-6.263.968-9.392 1.465-2.622.417-5.242.852-7.862 1.281l-1.602.275-.012-1.045c-.053-.859-.144-1.717-.154-2.576-.069-5.478-.112-10.956-.18-16.434-.042-3.429-.105-6.857-.175-10.285-.043-2.13-.089-4.261-.185-6.388-.052-1.143-.236-2.28-.311-3.423-.042-.657.016-1.319.029-1.979.817 1.583 1.616 3.178 2.456 4.749 1.327 2.484 3.441 4.314 5.344 6.311 7.523 7.892 12.864 17.068 16.193 27.433z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#409433'
d='M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#4FAA41'
d='M65.036 80.753c-.118 2.982-.23 5.964-.357 8.947-.035.83-.111 1.657-.168 2.485l-.765.289c-1.699-5.002-3.399-9.951-5.062-14.913-2.75-8.209-5.467-16.431-8.213-24.642a4498.887 4498.887 0 00-6.7-19.867c-.105-.31-.407-.552-.617-.826l4.896-9.002c.168.292.39.565.496.879a6167.476 6167.476 0 016.768 20.118c2.916 8.73 5.814 17.467 8.728 26.198.116.349.308.671.491 1.062l.67-.78-.167 10.052z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#4AA73C'
d='M43.155 32.227c.21.274.511.516.617.826a4498.887 4498.887 0 016.7 19.867c2.746 8.211 5.463 16.433 8.213 24.642 1.662 4.961 3.362 9.911 5.062 14.913l.765-.289-.171 2.426-.155.559c-.266 2.656-.49 5.318-.814 7.968-.163 1.328-.509 2.632-.772 3.947-.198-.287-.476-.548-.583-.866-5.467-16.297-10.918-32.6-16.376-48.9a3888.972 3888.972 0 00-5.242-15.551c-.089-.263-.34-.469-.516-.702l3.272-8.84z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#57AE47'
d='M65.202 70.702l-.67.78c-.183-.391-.375-.714-.491-1.062-2.913-8.731-5.812-17.468-8.728-26.198a6167.476 6167.476 0 00-6.768-20.118c-.105-.314-.327-.588-.496-.879l6.055-7.965c.191.255.463.482.562.769 1.681 4.921 3.347 9.848 5.003 14.778 1.547 4.604 3.071 9.215 4.636 13.813.105.308.47.526.714.786l.012 1.045c.058 8.082.115 16.167.171 24.251z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#60B24F'
d='M65.021 45.404c-.244-.26-.609-.478-.714-.786-1.565-4.598-3.089-9.209-4.636-13.813-1.656-4.93-3.322-9.856-5.003-14.778-.099-.287-.371-.514-.562-.769 1.969-1.928 3.877-3.925 5.925-5.764 1.821-1.634 3.285-3.386 3.352-5.968.003-.107.059-.214.145-.514l.519 1.306c-.013.661-.072 1.322-.029 1.979.075 1.143.259 2.28.311 3.423.096 2.127.142 4.258.185 6.388.069 3.428.132 6.856.175 10.285.067 5.478.111 10.956.18 16.434.008.861.098 1.718.152 2.577z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#A9AA88'
d='M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#B6B598'
d='M62.476 112.621c.196-.246.475-.464.574-.744.731-2.05 1.417-4.115 2.143-6.166.093-.262.341-.469.518-.701l1.255 2.754c-.248.352-.59.669-.728 1.061l-2.404 7.059c-.099.283-.437.483-.663.722l-.695-3.985z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#C2C1A7'
d='M63.171 116.605c.227-.238.564-.439.663-.722l2.404-7.059c.137-.391.48-.709.728-1.061l1.215 1.037c-.587.58-.913 1.25-.717 2.097l-.369 1.208c-.168.207-.411.387-.494.624-.839 2.403-1.64 4.819-2.485 7.222-.107.305-.404.544-.614.812-.109-1.387-.22-2.771-.331-4.158z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#CECDB7'
d='M63.503 120.763c.209-.269.506-.508.614-.812.845-2.402 1.646-4.818 2.485-7.222.083-.236.325-.417.494-.624l-.509 5.545c-.136.157-.333.294-.398.477-.575 1.614-1.117 3.24-1.694 4.854-.119.333-.347.627-.525.938-.158-.207-.441-.407-.454-.623-.051-.841-.016-1.688-.013-2.533z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#DBDAC7'
d='M63.969 123.919c.178-.312.406-.606.525-.938.578-1.613 1.119-3.239 1.694-4.854.065-.183.263-.319.398-.477l.012 3.64-1.218 3.124-1.411-.495z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#EBE9DC'
d='M65.38 124.415l1.218-3.124.251 3.696-1.469-.572z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#CECDB7'
d='M67.464 110.898c-.196-.847.129-1.518.717-2.097l.337.23-1.054 1.867z'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
fill='#4FAA41'
d='M64.316 95.172l-.066-.011-.066.01.155-.559-.023.56z'
/>
</svg>`}
/>
## Usage Instructions
Connect to any MongoDB database to execute queries, manage data, and perform database operations. Supports find, insert, update, delete, and aggregation operations with secure connection handling.
## Tools
### `mongodb_query`
Execute find operation on MongoDB collection
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MongoDB server hostname or IP address |
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | No | MongoDB username |
| `password` | string | No | MongoDB password |
| `authSource` | string | No | Authentication database |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `collection` | string | Yes | Collection name to query |
| `query` | string | No | MongoDB query filter as JSON string |
| `limit` | number | No | Maximum number of documents to return |
| `sort` | string | No | Sort criteria as JSON string |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `documents` | array | Array of documents returned from the query |
| `documentCount` | number | Number of documents returned |
### `mongodb_insert`
Insert documents into MongoDB collection
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MongoDB server hostname or IP address |
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | No | MongoDB username |
| `password` | string | No | MongoDB password |
| `authSource` | string | No | Authentication database |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `collection` | string | Yes | Collection name to insert into |
| `documents` | array | Yes | Array of documents to insert |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `documentCount` | number | Number of documents inserted |
| `insertedId` | string | ID of inserted document \(single insert\) |
| `insertedIds` | array | Array of inserted document IDs \(multiple insert\) |
### `mongodb_update`
Update documents in MongoDB collection
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MongoDB server hostname or IP address |
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | No | MongoDB username |
| `password` | string | No | MongoDB password |
| `authSource` | string | No | Authentication database |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `collection` | string | Yes | Collection name to update |
| `filter` | string | Yes | Filter criteria as JSON string |
| `update` | string | Yes | Update operations as JSON string |
| `upsert` | boolean | No | Create document if not found |
| `multi` | boolean | No | Update multiple documents |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `matchedCount` | number | Number of documents matched by filter |
| `modifiedCount` | number | Number of documents modified |
| `documentCount` | number | Total number of documents affected |
| `insertedId` | string | ID of inserted document \(if upsert\) |
### `mongodb_delete`
Delete documents from MongoDB collection
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MongoDB server hostname or IP address |
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | No | MongoDB username |
| `password` | string | No | MongoDB password |
| `authSource` | string | No | Authentication database |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `collection` | string | Yes | Collection name to delete from |
| `filter` | string | Yes | Filter criteria as JSON string |
| `multi` | boolean | No | Delete multiple documents |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `deletedCount` | number | Number of documents deleted |
| `documentCount` | number | Total number of documents affected |
### `mongodb_execute`
Execute MongoDB aggregation pipeline
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MongoDB server hostname or IP address |
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | No | MongoDB username |
| `password` | string | No | MongoDB password |
| `authSource` | string | No | Authentication database |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `collection` | string | Yes | Collection name to execute pipeline on |
| `pipeline` | string | Yes | Aggregation pipeline as JSON string |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `documents` | array | Array of documents returned from aggregation |
| `documentCount` | number | Number of documents returned |
## Notes
- Category: `tools`
- Type: `mongodb`

View File

@@ -1,180 +0,0 @@
---
title: MySQL
description: Connect to MySQL database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mysql"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 25.6 25.6'
>
<path
d='M179.076 94.886c-3.568-.1-6.336.268-8.656 1.25-.668.27-1.74.27-1.828 1.116.357.355.4.936.713 1.428.535.893 1.473 2.096 2.32 2.72l2.855 2.053c1.74 1.07 3.703 1.695 5.398 2.766.982.625 1.963 1.428 2.945 2.098.5.357.803.938 1.428 1.16v-.135c-.312-.4-.402-.98-.713-1.428l-1.34-1.293c-1.293-1.74-2.9-3.258-4.64-4.506-1.428-.982-4.55-2.32-5.13-3.97l-.088-.1c.98-.1 2.14-.447 3.078-.715 1.518-.4 2.9-.312 4.46-.713l2.143-.625v-.4c-.803-.803-1.383-1.874-2.23-2.632-2.275-1.963-4.775-3.882-7.363-5.488-1.383-.892-3.168-1.473-4.64-2.23-.537-.268-1.428-.402-1.74-.848-.805-.98-1.25-2.275-1.83-3.436l-3.658-7.763c-.803-1.74-1.295-3.48-2.275-5.086-4.596-7.585-9.594-12.18-17.268-16.687-1.65-.937-3.613-1.34-5.7-1.83l-3.346-.18c-.715-.312-1.428-1.16-2.053-1.562-2.543-1.606-9.102-5.086-10.977-.5-1.205 2.9 1.785 5.755 2.8 7.228.76 1.026 1.74 2.186 2.277 3.346.3.758.4 1.562.713 2.365.713 1.963 1.383 4.15 2.32 5.98.5.937 1.025 1.92 1.65 2.767.357.5.982.714 1.115 1.517-.625.893-.668 2.23-1.025 3.347-1.607 5.042-.982 11.288 1.293 15 .715 1.115 2.4 3.57 4.686 2.632 2.008-.803 1.56-3.346 2.14-5.577.135-.535.045-.892.312-1.25v.1l1.83 3.703c1.383 2.186 3.793 4.462 5.8 5.98 1.07.803 1.918 2.187 3.256 2.677v-.135h-.088c-.268-.4-.67-.58-1.027-.892-.803-.803-1.695-1.785-2.32-2.677-1.873-2.498-3.523-5.265-4.996-8.12-.715-1.383-1.34-2.9-1.918-4.283-.27-.536-.27-1.34-.715-1.606-.67.98-1.65 1.83-2.143 3.034-.848 1.918-.936 4.283-1.248 6.737-.18.045-.1 0-.18.1-1.426-.356-1.918-1.83-2.453-3.078-1.338-3.168-1.562-8.254-.402-11.913.312-.937 1.652-3.882 1.117-4.774-.27-.848-1.16-1.338-1.652-2.008-.58-.848-1.203-1.918-1.605-2.855-1.07-2.5-1.605-5.265-2.766-7.764-.537-1.16-1.473-2.365-2.232-3.435-.848-1.205-1.783-2.053-2.453-3.48-.223-.5-.535-1.294-.178-1.83.088-.357.268-.5.623-.58.58-.5 2.232.134 2.812.4 1.65.67 3.033 1.294 4.416 2.23.625.446 1.295 1.294 2.098 1.518h.938c1.428.312 3.033.1 4.37.5 2.365.76 4.506 1.874 6.426 3.08 5.844 3.703 10.664 8.968 13.92 15.26.535 1.026.758 1.963 1.25 3.034.938 2.187 2.098 4.417 3.033 6.56.938 2.097 1.83 4.24 3.168 5.98.67.937 3.346 1.427 4.55 1.918.893.4 2.275.76 3.08 1.25 1.516.937 3.033 2.008 4.46 3.034.713.534 2.945 1.65 3.078 2.54zm-45.5-38.772a7.09 7.09 0 0 0-1.828.223v.1h.088c.357.714.982 1.205 1.428 1.83l1.027 2.142.088-.1c.625-.446.938-1.16.938-2.23-.268-.312-.312-.625-.535-.937-.268-.446-.848-.67-1.206-1.026z'
transform='matrix(.390229 0 0 .38781 -46.300037 -16.856717)'
fillRule='evenodd'
fill='#00678c'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The [MySQL](https://www.mysql.com/) tool enables you to connect to any MySQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data.
With the MySQL tool, you can:
- **Query data**: Execute SELECT queries to retrieve data from your MySQL tables using the `mysql_query` operation.
- **Insert records**: Add new rows to your tables with the `mysql_insert` operation by specifying the table and data to insert.
- **Update records**: Modify existing data in your tables using the `mysql_update` operation, providing the table, new data, and WHERE conditions.
- **Delete records**: Remove rows from your tables with the `mysql_delete` operation, specifying the table and WHERE conditions.
- **Execute raw SQL**: Run any custom SQL command using the `mysql_execute` operation for advanced use cases.
The MySQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your MySQL data programmatically.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
## Tools
### `mysql_query`
Execute SELECT query on MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | SQL SELECT query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows returned |
### `mysql_insert`
Insert new record into MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to insert into |
| `data` | object | Yes | Data to insert as key-value pairs |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of inserted rows |
| `rowCount` | number | Number of rows inserted |
### `mysql_update`
Update existing records in MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to update |
| `data` | object | Yes | Data to update as key-value pairs |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of updated rows |
| `rowCount` | number | Number of rows updated |
### `mysql_delete`
Delete records from MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to delete from |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of deleted rows |
| `rowCount` | number | Number of rows deleted |
### `mysql_execute`
Execute raw SQL query on MySQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | MySQL server hostname or IP address |
| `port` | number | Yes | MySQL server port \(default: 3306\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | Raw SQL query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows affected |
## Notes
- Category: `tools`
- Type: `mysql`

View File

@@ -1,106 +0,0 @@
---
title: Parallel AI
description: Search with Parallel AI
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="parallel_ai"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
fill='currentColor'
viewBox='0 0 271 270'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M267.804 105.65H193.828C194.026 106.814 194.187 107.996 194.349 109.178H76.6703C76.4546 110.736 76.2388 112.312 76.0591 113.87H1.63342C1.27387 116.198 0.950289 118.543 0.698608 120.925H75.3759C75.2501 122.483 75.1602 124.059 75.0703 125.617H195.949C196.003 126.781 196.057 127.962 196.093 129.144H270.68V125.384C270.195 118.651 269.242 112.061 267.804 105.65Z'
fill='#1D1C1A'
/>
<path
d='M195.949 144.401H75.0703C75.1422 145.977 75.2501 147.535 75.3759 149.093H0.698608C0.950289 151.457 1.2559 153.802 1.63342 156.148H76.0591C76.2388 157.724 76.4366 159.282 76.6703 160.84H194.349C194.187 162.022 194.008 163.186 193.828 164.367H267.804C269.242 157.957 270.195 151.367 270.68 144.634V140.874H196.093C196.057 142.055 196.003 143.219 195.949 144.401Z'
fill='#1D1C1A'
/>
<path
d='M190.628 179.642H80.3559C80.7514 181.218 81.1828 182.776 81.6143 184.334H9.30994C10.2448 186.715 11.2515 189.061 12.3121 191.389H83.7536C84.2749 192.965 84.7962 194.523 85.3535 196.08H185.594C185.163 197.262 184.732 198.426 184.282 199.608H254.519C258.6 192.177 261.98 184.316 264.604 176.114H191.455C191.185 177.296 190.898 178.46 190.61 179.642H190.628Z'
fill='#1D1C1A'
/>
<path
d='M177.666 214.883H93.3352C94.1082 216.458 94.9172 218.034 95.7441 219.574H29.8756C31.8351 221.992 33.8666 224.337 35.9699 226.63H99.6632C100.598 228.205 101.551 229.781 102.522 231.321H168.498C167.761 232.503 167.006 233.685 166.233 234.849H226.762C234.474 227.847 241.36 219.95 247.292 211.355H179.356C178.799 212.537 178.26 213.719 177.684 214.883H177.666Z'
fill='#1D1C1A'
/>
<path
d='M154.943 250.106H116.058C117.371 251.699 118.701 253.257 120.067 254.797H73.021C91.6094 264.431 112.715 269.946 135.096 270C135.24 270 135.366 270 135.492 270C135.618 270 135.761 270 135.887 270C164.04 269.911 190.178 261.28 211.805 246.56H157.748C156.813 247.742 155.878 248.924 154.925 250.088L154.943 250.106Z'
fill='#1D1C1A'
/>
<path
d='M116.059 19.9124H154.943C155.896 21.0764 156.831 22.2582 157.766 23.4401H211.823C190.179 8.72065 164.058 0.0895344 135.906 0C135.762 0 135.636 0 135.51 0C135.384 0 135.24 0 135.115 0C112.715 0.0716275 91.6277 5.56904 73.0393 15.2029H120.086C118.719 16.7429 117.389 18.3187 116.077 19.8945L116.059 19.9124Z'
fill='#1D1C1A'
/>
<path
d='M93.3356 55.1532H177.667C178.242 56.3171 178.799 57.499 179.339 58.6808H247.274C241.342 50.0855 234.457 42.1886 226.744 35.187H166.215C166.988 36.351 167.743 37.5328 168.48 38.7147H102.504C101.533 40.2726 100.58 41.8305 99.6456 43.4063H35.9523C33.831 45.6804 31.7996 48.0262 29.858 50.4616H95.7265C94.8996 52.0195 94.1086 53.5774 93.3176 55.1532H93.3356Z'
fill='#1D1C1A'
/>
<path
d='M80.3736 90.3758H190.646C190.933 91.5398 191.221 92.7216 191.491 93.9035H264.64C262.015 85.7021 258.636 77.841 254.555 70.4097H184.318C184.767 71.5736 185.199 72.7555 185.63 73.9373H85.3893C84.832 75.4952 84.2927 77.0531 83.7893 78.6289H12.3479C11.2872 80.9389 10.2805 83.2847 9.3457 85.6842H81.65C81.2186 87.2421 80.7871 88.8 80.3916 90.3758H80.3736Z'
fill='#1D1C1A'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Parallel AI](https://parallel.ai/) is an advanced web search and content extraction platform designed to deliver comprehensive, high-quality results for any query. By leveraging intelligent processing and large-scale data extraction, Parallel AI enables users and agents to access, analyze, and synthesize information from across the web with speed and accuracy.
With Parallel AI, you can:
- **Search the web intelligently**: Retrieve relevant, up-to-date information from a wide range of sources
- **Extract and summarize content**: Get concise, meaningful excerpts from web pages and documents
- **Customize search objectives**: Tailor queries to specific needs or questions for targeted results
- **Process results at scale**: Handle large volumes of search results with advanced processing options
- **Integrate with workflows**: Use Parallel AI within Sim to automate research, content gathering, and knowledge extraction
- **Control output granularity**: Specify the number of results and the amount of content per result
- **Secure API access**: Protect your searches and data with API key authentication
In Sim, the Parallel AI integration empowers your agents to perform web searches and extract content programmatically. This enables powerful automation scenarios such as real-time research, competitive analysis, content monitoring, and knowledge base creation. By connecting Sim with Parallel AI, you unlock the ability for agents to gather, process, and utilize web data as part of your automated workflows.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search the web using Parallel AI's advanced search capabilities. Get comprehensive results with intelligent processing and content extraction.
## Tools
### `parallel_search`
Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `objective` | string | Yes | The search objective or question to answer |
| `search_queries` | string | No | Optional comma-separated list of search queries to execute |
| `processor` | string | No | Processing method: base or pro \(default: base\) |
| `max_results` | number | No | Maximum number of results to return \(default: 5\) |
| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Search results with excerpts from relevant pages |
## Notes
- Category: `tools`
- Type: `parallel_ai`

View File

@@ -1,188 +0,0 @@
---
title: PostgreSQL
description: Connect to PostgreSQL database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="postgresql"
color="#336791"
icon={true}
iconSvg={`<svg className="block-icon"
viewBox='-4 0 264 264'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMinYMin meet'
>
<path d='M255.008 158.086c-1.535-4.649-5.556-7.887-10.756-8.664-2.452-.366-5.26-.21-8.583.475-5.792 1.195-10.089 1.65-13.225 1.738 11.837-19.985 21.462-42.775 27.003-64.228 8.96-34.689 4.172-50.492-1.423-57.64C233.217 10.847 211.614.683 185.552.372c-13.903-.17-26.108 2.575-32.475 4.549-5.928-1.046-12.302-1.63-18.99-1.738-12.537-.2-23.614 2.533-33.079 8.15-5.24-1.772-13.65-4.27-23.362-5.864-22.842-3.75-41.252-.828-54.718 8.685C6.622 25.672-.937 45.684.461 73.634c.444 8.874 5.408 35.874 13.224 61.48 4.492 14.718 9.282 26.94 14.237 36.33 7.027 13.315 14.546 21.156 22.987 23.972 4.731 1.576 13.327 2.68 22.368-4.85 1.146 1.388 2.675 2.767 4.704 4.048 2.577 1.625 5.728 2.953 8.875 3.74 11.341 2.835 21.964 2.126 31.027-1.848.056 1.612.099 3.152.135 4.482.06 2.157.12 4.272.199 6.25.537 13.374 1.447 23.773 4.143 31.049.148.4.347 1.01.557 1.657 1.345 4.118 3.594 11.012 9.316 16.411 5.925 5.593 13.092 7.308 19.656 7.308 3.292 0 6.433-.432 9.188-1.022 9.82-2.105 20.973-5.311 29.041-16.799 7.628-10.86 11.336-27.217 12.007-52.99.087-.729.167-1.425.244-2.088l.16-1.362 1.797.158.463.031c10.002.456 22.232-1.665 29.743-5.154 5.935-2.754 24.954-12.795 20.476-26.351' />
<path
d='M237.906 160.722c-29.74 6.135-31.785-3.934-31.785-3.934 31.4-46.593 44.527-105.736 33.2-120.211-30.904-39.485-84.399-20.811-85.292-20.327l-.287.052c-5.876-1.22-12.451-1.946-19.842-2.067-13.456-.22-23.664 3.528-31.41 9.402 0 0-95.43-39.314-90.991 49.444.944 18.882 27.064 142.873 58.218 105.422 11.387-13.695 22.39-25.274 22.39-25.274 5.464 3.63 12.006 5.482 18.864 4.817l.533-.452c-.166 1.7-.09 3.363.213 5.332-8.026 8.967-5.667 10.541-21.711 13.844-16.235 3.346-6.698 9.302-.471 10.86 7.549 1.887 25.013 4.561 36.813-11.958l-.47 1.885c3.144 2.519 5.352 16.383 4.982 28.952-.37 12.568-.617 21.197 1.86 27.937 2.479 6.74 4.948 21.905 26.04 17.386 17.623-3.777 26.756-13.564 28.027-29.89.901-11.606 2.942-9.89 3.07-20.267l1.637-4.912c1.887-15.733.3-20.809 11.157-18.448l2.64.232c7.99.363 18.45-1.286 24.589-4.139 13.218-6.134 21.058-16.377 8.024-13.686h.002'
fill='#336791'
/>
<path
d='M108.076 81.525c-2.68-.373-5.107-.028-6.335.902-.69.523-.904 1.129-.962 1.546-.154 1.105.62 2.327 1.096 2.957 1.346 1.784 3.312 3.01 5.258 3.28.282.04.563.058.842.058 3.245 0 6.196-2.527 6.456-4.392.325-2.336-3.066-3.893-6.355-4.35M196.86 81.599c-.256-1.831-3.514-2.353-6.606-1.923-3.088.43-6.082 1.824-5.832 3.659.2 1.427 2.777 3.863 5.827 3.863.258 0 .518-.017.78-.054 2.036-.282 3.53-1.575 4.24-2.32 1.08-1.136 1.706-2.402 1.591-3.225'
fill='#FFF'
/>
<path
d='M247.802 160.025c-1.134-3.429-4.784-4.532-10.848-3.28-18.005 3.716-24.453 1.142-26.57-.417 13.995-21.32 25.508-47.092 31.719-71.137 2.942-11.39 4.567-21.968 4.7-30.59.147-9.463-1.465-16.417-4.789-20.665-13.402-17.125-33.072-26.311-56.882-26.563-16.369-.184-30.199 4.005-32.88 5.183-5.646-1.404-11.801-2.266-18.502-2.376-12.288-.199-22.91 2.743-31.704 8.74-3.82-1.422-13.692-4.811-25.765-6.756-20.872-3.36-37.458-.814-49.294 7.571-14.123 10.006-20.643 27.892-19.38 53.16.425 8.501 5.269 34.653 12.913 59.698 10.062 32.964 21 51.625 32.508 55.464 1.347.449 2.9.763 4.613.763 4.198 0 9.345-1.892 14.7-8.33a529.832 529.832 0 0 1 20.261-22.926c4.524 2.428 9.494 3.784 14.577 3.92.01.133.023.266.035.398a117.66 117.66 0 0 0-2.57 3.175c-3.522 4.471-4.255 5.402-15.592 7.736-3.225.666-11.79 2.431-11.916 8.435-.136 6.56 10.125 9.315 11.294 9.607 4.074 1.02 7.999 1.523 11.742 1.523 9.103 0 17.114-2.992 23.516-8.781-.197 23.386.778 46.43 3.586 53.451 2.3 5.748 7.918 19.795 25.664 19.794 2.604 0 5.47-.303 8.623-.979 18.521-3.97 26.564-12.156 29.675-30.203 1.665-9.645 4.522-32.676 5.866-45.03 2.836.885 6.487 1.29 10.434 1.289 8.232 0 17.731-1.749 23.688-4.514 6.692-3.108 18.768-10.734 16.578-17.36zm-44.106-83.48c-.061 3.647-.563 6.958-1.095 10.414-.573 3.717-1.165 7.56-1.314 12.225-.147 4.54.42 9.26.968 13.825 1.108 9.22 2.245 18.712-2.156 28.078a36.508 36.508 0 0 1-1.95-4.009c-.547-1.326-1.735-3.456-3.38-6.404-6.399-11.476-21.384-38.35-13.713-49.316 2.285-3.264 8.084-6.62 22.64-4.813zm-17.644-61.787c21.334.471 38.21 8.452 50.158 23.72 9.164 11.711-.927 64.998-30.14 110.969a171.33 171.33 0 0 0-.886-1.117l-.37-.462c7.549-12.467 6.073-24.802 4.759-35.738-.54-4.488-1.05-8.727-.92-12.709.134-4.22.692-7.84 1.232-11.34.663-4.313 1.338-8.776 1.152-14.037.139-.552.195-1.204.122-1.978-.475-5.045-6.235-20.144-17.975-33.81-6.422-7.475-15.787-15.84-28.574-21.482 5.5-1.14 13.021-2.203 21.442-2.016zM66.674 175.778c-5.9 7.094-9.974 5.734-11.314 5.288-8.73-2.912-18.86-21.364-27.791-50.624-7.728-25.318-12.244-50.777-12.602-57.916-1.128-22.578 4.345-38.313 16.268-46.769 19.404-13.76 51.306-5.524 64.125-1.347-.184.182-.376.352-.558.537-21.036 21.244-20.537 57.54-20.485 59.759-.002.856.07 2.068.168 3.735.362 6.105 1.036 17.467-.764 30.334-1.672 11.957 2.014 23.66 10.111 32.109a36.275 36.275 0 0 0 2.617 2.468c-3.604 3.86-11.437 12.396-19.775 22.426zm22.479-29.993c-6.526-6.81-9.49-16.282-8.133-25.99 1.9-13.592 1.199-25.43.822-31.79-.053-.89-.1-1.67-.127-2.285 3.073-2.725 17.314-10.355 27.47-8.028 4.634 1.061 7.458 4.217 8.632 9.645 6.076 28.103.804 39.816-3.432 49.229-.873 1.939-1.698 3.772-2.402 5.668l-.546 1.466c-1.382 3.706-2.668 7.152-3.465 10.424-6.938-.02-13.687-2.984-18.819-8.34zm1.065 37.9c-2.026-.506-3.848-1.385-4.917-2.114.893-.42 2.482-.992 5.238-1.56 13.337-2.745 15.397-4.683 19.895-10.394 1.031-1.31 2.2-2.794 3.819-4.602l.002-.002c2.411-2.7 3.514-2.242 5.514-1.412 1.621.67 3.2 2.702 3.84 4.938.303 1.056.643 3.06-.47 4.62-9.396 13.156-23.088 12.987-32.921 10.526zm69.799 64.952c-16.316 3.496-22.093-4.829-25.9-14.346-2.457-6.144-3.665-33.85-2.808-64.447.011-.407-.047-.8-.159-1.17a15.444 15.444 0 0 0-.456-2.162c-1.274-4.452-4.379-8.176-8.104-9.72-1.48-.613-4.196-1.738-7.46-.903.696-2.868 1.903-6.107 3.212-9.614l.549-1.475c.618-1.663 1.394-3.386 2.214-5.21 4.433-9.848 10.504-23.337 3.915-53.81-2.468-11.414-10.71-16.988-23.204-15.693-7.49.775-14.343 3.797-17.761 5.53-.735.372-1.407.732-2.035 1.082.954-11.5 4.558-32.992 18.04-46.59 8.489-8.56 19.794-12.788 33.568-12.56 27.14.444 44.544 14.372 54.366 25.979 8.464 10.001 13.047 20.076 14.876 25.51-13.755-1.399-23.11 1.316-27.852 8.096-10.317 14.748 5.644 43.372 13.315 57.129 1.407 2.521 2.621 4.7 3.003 5.626 2.498 6.054 5.732 10.096 8.093 13.046.724.904 1.426 1.781 1.96 2.547-4.166 1.201-11.649 3.976-10.967 17.847-.55 6.96-4.461 39.546-6.448 51.059-2.623 15.21-8.22 20.875-23.957 24.25zm68.104-77.936c-4.26 1.977-11.389 3.46-18.161 3.779-7.48.35-11.288-.838-12.184-1.569-.42-8.644 2.797-9.547 6.202-10.503.535-.15 1.057-.297 1.561-.473.313.255.656.508 1.032.756 6.012 3.968 16.735 4.396 31.874 1.271l.166-.033c-2.042 1.909-5.536 4.471-10.49 6.772z'
fill='#FFF'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
The [PostgreSQL](https://www.postgresql.org/) tool enables you to connect to any PostgreSQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data.
With the PostgreSQL tool, you can:
- **Query data**: Execute SELECT queries to retrieve data from your PostgreSQL tables using the `postgresql_query` operation.
- **Insert records**: Add new rows to your tables with the `postgresql_insert` operation by specifying the table and data to insert.
- **Update records**: Modify existing data in your tables using the `postgresql_update` operation, providing the table, new data, and WHERE conditions.
- **Delete records**: Remove rows from your tables with the `postgresql_delete` operation, specifying the table and WHERE conditions.
- **Execute raw SQL**: Run any custom SQL command using the `postgresql_execute` operation for advanced use cases.
The PostgreSQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your PostgreSQL data programmatically.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any PostgreSQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
## Tools
### `postgresql_query`
Execute a SELECT query on PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | SQL SELECT query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows returned |
### `postgresql_insert`
Insert data into PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to insert data into |
| `data` | object | Yes | Data object to insert \(key-value pairs\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Inserted data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows inserted |
### `postgresql_update`
Update data in PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to update data in |
| `data` | object | Yes | Data object with fields to update \(key-value pairs\) |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Updated data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows updated |
### `postgresql_delete`
Delete data from PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `table` | string | Yes | Table name to delete data from |
| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Deleted data \(if RETURNING clause used\) |
| `rowCount` | number | Number of rows deleted |
### `postgresql_execute`
Execute raw SQL query on PostgreSQL database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | PostgreSQL server hostname or IP address |
| `port` | number | Yes | PostgreSQL server port \(default: 5432\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Database username |
| `password` | string | Yes | Database password |
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
| `query` | string | Yes | Raw SQL query to execute |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `rows` | array | Array of rows returned from the query |
| `rowCount` | number | Number of rows affected |
## Notes
- Category: `tools`
- Type: `postgresql`

View File

@@ -304,15 +304,6 @@ export default function LoginPage({
return
}
const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase())
if (!emailValidation.isValid) {
setResetStatus({
type: 'error',
message: 'Please enter a valid email address',
})
return
}
try {
setIsSubmittingReset(true)
setResetStatus({ type: null, message: '' })
@@ -330,23 +321,7 @@ export default function LoginPage({
if (!response.ok) {
const errorData = await response.json()
let errorMessage = errorData.message || 'Failed to request password reset'
if (
errorMessage.includes('Invalid body parameters') ||
errorMessage.includes('invalid email')
) {
errorMessage = 'Please enter a valid email address'
} else if (errorMessage.includes('Email is required')) {
errorMessage = 'Please enter your email address'
} else if (
errorMessage.includes('user not found') ||
errorMessage.includes('User not found')
) {
errorMessage = 'No account found with this email address'
}
throw new Error(errorMessage)
throw new Error(errorData.message || 'Failed to request password reset')
}
setResetStatus({
@@ -522,8 +497,7 @@ export default function LoginPage({
Reset Password
</DialogTitle>
<DialogDescription className='text-neutral-300 text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
Enter your email address and we'll send you a link to reset your password.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
@@ -538,20 +512,14 @@ export default function LoginPage({
placeholder='Enter your email'
required
type='email'
className={cn(
'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20',
resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500'
)}
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20'
/>
{resetStatus.type === 'error' && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
</div>
{resetStatus.type === 'success' && (
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
<p>{resetStatus.message}</p>
{resetStatus.type && (
<div
className={`text-sm ${resetStatus.type === 'success' ? 'text-[#4CAF50]' : 'text-red-500'}`}
>
{resetStatus.message}
</div>
)}
<Button

View File

@@ -166,9 +166,8 @@ describe('SignupPage', () => {
})
})
it('should automatically trim spaces from name input', async () => {
it('should prevent submission with invalid name validation', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
@@ -177,20 +176,22 @@ describe('SignupPage', () => {
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use name with leading/trailing spaces which should fail validation
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
// Should not call signUp because validation failed
expect(mockSignUp).not.toHaveBeenCalled()
// Should show validation error
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
expect.objectContaining({
name: 'John Doe',
email: 'user@company.com',
password: 'Password123!',
}),
expect.any(Object)
)
expect(
screen.getByText(
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
)
).toBeInTheDocument()
})
})

View File

@@ -49,6 +49,10 @@ const NAME_VALIDATIONS = {
regex: /^(?!.*\s\s).*$/,
message: 'Name cannot contain consecutive spaces.',
},
noLeadingTrailingSpaces: {
test: (value: string) => value === value.trim(),
message: 'Name cannot start or end with spaces.',
},
}
const validateEmailField = (emailValue: string): string[] => {
@@ -171,6 +175,10 @@ function SignupFormContent({
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
}
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
}
return errors
}
@@ -185,10 +193,11 @@ function SignupFormContent({
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value
setName(rawValue)
const newName = e.target.value
setName(newName)
const errors = validateName(rawValue)
// Silently validate but don't show errors until submit
const errors = validateName(newName)
setNameErrors(errors)
setShowNameValidationError(false)
}
@@ -215,21 +224,23 @@ function SignupFormContent({
const formData = new FormData(e.currentTarget)
const emailValue = formData.get('email') as string
const passwordValue = formData.get('password') as string
const nameValue = formData.get('name') as string
const name = formData.get('name') as string
const trimmedName = nameValue.trim()
const nameValidationErrors = validateName(trimmedName)
// Validate name on submit
const nameValidationErrors = validateName(name)
setNameErrors(nameValidationErrors)
setShowNameValidationError(nameValidationErrors.length > 0)
// Validate email on submit
const emailValidationErrors = validateEmailField(emailValue)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
// Validate password on submit
const errors = validatePassword(passwordValue)
setPasswordErrors(errors)
// Only show validation errors if there are any
setShowValidationError(errors.length > 0)
try {
@@ -238,6 +249,7 @@ function SignupFormContent({
emailValidationErrors.length > 0 ||
errors.length > 0
) {
// Prioritize name errors first, then email errors, then password errors
if (nameValidationErrors.length > 0) {
setNameErrors([nameValidationErrors[0]])
setShowNameValidationError(true)
@@ -254,6 +266,8 @@ function SignupFormContent({
return
}
// Check if name will be truncated and warn user
const trimmedName = name.trim()
if (trimmedName.length > 100) {
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
setShowNameValidationError(true)
@@ -323,6 +337,7 @@ function SignupFormContent({
logger.info('Session refreshed after successful signup')
} catch (sessionError) {
logger.error('Failed to refresh session after signup:', sessionError)
// Continue anyway - the verification flow will handle this
}
// For new signups, always require verification

View File

@@ -215,28 +215,20 @@ export function useVerification({
setOtp(value)
}
// Auto-submit when OTP is complete
useEffect(() => {
if (otp.length === 6 && email && !isLoading && !isVerified) {
const timeoutId = setTimeout(() => {
verifyCode()
}, 300) // Small delay to ensure UI is ready
return () => clearTimeout(timeoutId)
}
}, [otp, email, isLoading, isVerified])
useEffect(() => {
if (typeof window !== 'undefined') {
if (!isProduction || !hasResendKey) {
const storedEmail = sessionStorage.getItem('verificationEmail')
logger.info('Auto-verifying user', { email: storedEmail })
}
const isDevOrDocker = !isProduction || isTruthy(env.DOCKER_BUILD)
// Auto-verify and redirect in development/docker environments
if (isDevOrDocker || !hasResendKey) {
setIsVerified(true)
// Clear verification requirement cookie (same as manual verification)
document.cookie =
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'

View File

@@ -143,6 +143,7 @@ export const sampleWorkflowState = {
],
loops: {},
parallels: {},
whiles: {},
lastSaved: Date.now(),
isDeployed: false,
}

View File

@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, userStats } from '@/db/schema'

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { isDev } from '@/lib/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -13,7 +12,7 @@ import { getEmailDomain } from '@/lib/urls/utils'
import { decryptSecret } from '@/lib/utils'
import { getBlock } from '@/blocks'
import { db } from '@/db'
import { chat, userStats, workflow } from '@/db/schema'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
@@ -421,7 +420,7 @@ export async function executeWorkflowForChat(
// Use deployed state for chat execution (this is the stable, deployed version)
const deployedState = workflowResult[0].deployedState as WorkflowState
const { blocks, edges, loops, parallels } = deployedState
const { blocks, edges, loops, parallels, whiles } = deployedState
// Prepare for execution, similar to use-workflow-execution.ts
const mergedStates = mergeSubblockState(blocks)
@@ -454,21 +453,18 @@ export async function executeWorkflowForChat(
{} as Record<string, Record<string, any>>
)
// Get user environment variables with workspace precedence
// Get user environment variables for this workflow
let envVars: Record<string, string> = {}
try {
const wfWorkspaceRow = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
const envResult = await db
.select()
.from(envTable)
.where(eq(envTable.userId, deployment.userId))
.limit(1)
const workspaceId = wfWorkspaceRow[0]?.workspaceId || undefined
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
deployment.userId,
workspaceId
)
envVars = { ...personalEncrypted, ...workspaceEncrypted }
if (envResult.length > 0 && envResult[0].variables) {
envVars = envResult[0].variables as Record<string, string>
}
} catch (error) {
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
}
@@ -501,6 +497,7 @@ export async function executeWorkflowForChat(
filteredEdges,
loops,
parallels,
whiles,
true // Enable validation during execution
)

View File

@@ -224,7 +224,6 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
}),
})
@@ -287,7 +286,6 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
}),
})
@@ -339,7 +337,6 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
}),
})
@@ -428,7 +425,6 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'ask',
messageId: 'mock-uuid-1234-5678',
depth: 0,
}),
})

View File

@@ -12,9 +12,9 @@ import {
import { getCopilotModel } from '@/lib/copilot/config'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/env'
import { generateChatTitle } from '@/lib/generate-chat-title'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { generateChatTitle } from '@/lib/sim-agent/utils'
import { createFileContent, isSupportedFileType } from '@/lib/uploads/file-utils'
import { S3_COPILOT_CONFIG } from '@/lib/uploads/setup'
import { downloadFile, getStorageProvider } from '@/lib/uploads/storage-client'
@@ -50,25 +50,13 @@ const ChatMessageSchema = z.object({
contexts: z
.array(
z.object({
kind: z.enum([
'past_chat',
'workflow',
'current_workflow',
'blocks',
'logs',
'workflow_block',
'knowledge',
'templates',
'docs',
]),
kind: z.enum(['past_chat', 'workflow', 'blocks', 'logs', 'knowledge', 'templates']),
label: z.string(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
knowledgeId: z.string().optional(),
blockId: z.string().optional(),
templateId: z.string().optional(),
executionId: z.string().optional(),
// For workflow_block, provide both workflowId and blockId
})
)
.optional(),
@@ -108,8 +96,6 @@ export async function POST(req: NextRequest) {
conversationId,
contexts,
} = ChatMessageSchema.parse(body)
// Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID()
try {
logger.info(`[${tracker.requestId}] Received chat POST`, {
hasContexts: Array.isArray(contexts),
@@ -119,7 +105,6 @@ export async function POST(req: NextRequest) {
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
@@ -130,18 +115,13 @@ export async function POST(req: NextRequest) {
if (Array.isArray(contexts) && contexts.length > 0) {
try {
const { processContextsServer } = await import('@/lib/copilot/process-contents')
const processed = await processContextsServer(contexts as any, authenticatedUserId, message)
const processed = await processContextsServer(contexts as any, authenticatedUserId)
agentContexts = processed
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
})
if (Array.isArray(contexts) && contexts.length > 0 && agentContexts.length === 0) {
logger.warn(
`[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
)
}
} catch (e) {
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
}
@@ -371,7 +351,6 @@ export async function POST(req: NextRequest) {
stream: stream,
streamToolCalls: true,
mode: mode,
messageId: userMessageIdToUse,
...(providerConfig ? { provider: providerConfig } : {}),
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
@@ -417,7 +396,7 @@ export async function POST(req: NextRequest) {
if (stream && simAgentResponse.body) {
// Create user message to save
const userMessage = {
id: userMessageIdToUse, // Consistent ID used for request and persistence
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
role: 'user',
content: message,
timestamp: new Date().toISOString(),
@@ -495,6 +474,16 @@ export async function POST(req: NextRequest) {
break
}
// Check if client disconnected before processing chunk
try {
// Forward the chunk to client immediately
controller.enqueue(value)
} catch (error) {
// Client disconnected - stop reading from sim agent
reader.cancel() // Stop reading from sim agent
break
}
// Decode and parse SSE events for logging and capturing content
const decodedChunk = decoder.decode(value, { stream: true })
buffer += decodedChunk
@@ -594,47 +583,6 @@ export async function POST(req: NextRequest) {
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
@@ -667,37 +615,10 @@ export async function POST(req: NextRequest) {
logger.debug(`[${tracker.requestId}] Processing remaining buffer: "${buffer}"`)
if (buffer.startsWith('data: ')) {
try {
const jsonStr = buffer.slice(6)
const event = JSON.parse(jsonStr)
const event = JSON.parse(buffer.slice(6))
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}"`)
}
@@ -813,7 +734,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: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
role: 'user',
content: message,
timestamp: new Date().toISOString(),

View File

@@ -71,6 +71,7 @@ export async function POST(request: NextRequest) {
edges: checkpointState?.edges || [],
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
whiles: checkpointState?.whiles || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
hasActiveWebhook: checkpointState?.hasActiveWebhook || false,

View File

@@ -1,80 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { env } from '@/lib/env'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const BodySchema = z
.object({
// Do NOT send id; messageId is the unique correlator
userId: z.string().optional(),
chatId: z.string().uuid().optional(),
messageId: z.string().optional(),
depth: z.number().int().nullable().optional(),
maxEnabled: z.boolean().nullable().optional(),
createdAt: z.union([z.string().datetime(), z.date()]).optional(),
diffCreated: z.boolean().nullable().optional(),
diffAccepted: z.boolean().nullable().optional(),
duration: z.number().int().nullable().optional(),
inputTokens: z.number().int().nullable().optional(),
outputTokens: z.number().int().nullable().optional(),
aborted: z.boolean().nullable().optional(),
})
.passthrough()
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const json = await req.json().catch(() => ({}))
const parsed = BodySchema.safeParse(json)
if (!parsed.success) {
return createBadRequestResponse('Invalid request body for copilot stats')
}
const body = parsed.data as any
// Build outgoing payload for Sim Agent; do not include id
const payload: Record<string, any> = {
...body,
userId: body.userId || userId,
createdAt: body.createdAt || new Date().toISOString(),
}
payload.id = undefined
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/stats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(payload),
})
// Prefer not to block clients; still relay status
let agentJson: any = null
try {
agentJson = await agentRes.json()
} catch {}
if (!agentRes.ok) {
const message = (agentJson && (agentJson.error || agentJson.message)) || 'Upstream error'
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
return NextResponse.json({ success: true })
} catch (error) {
return createInternalServerErrorResponse('Failed to forward copilot stats')
}
}

View File

@@ -10,6 +10,7 @@ import type { EnvironmentVariable } from '@/stores/settings/environment/types'
const logger = createLogger('EnvironmentAPI')
// Schema for environment variable updates
const EnvVarSchema = z.object({
variables: z.record(z.string()),
})
@@ -29,13 +30,17 @@ export async function POST(req: NextRequest) {
try {
const { variables } = EnvVarSchema.parse(body)
const encryptedVariables = await Promise.all(
Object.entries(variables).map(async ([key, value]) => {
// Encrypt all variables
const encryptedVariables = await Object.entries(variables).reduce(
async (accPromise, [key, value]) => {
const acc = await accPromise
const { encrypted } = await encryptSecret(value)
return [key, encrypted] as const
})
).then((entries) => Object.fromEntries(entries))
return { ...acc, [key]: encrypted }
},
Promise.resolve({})
)
// Replace all environment variables for user
await db
.insert(environment)
.values({
@@ -75,6 +80,7 @@ export async function GET(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session directly in the API route
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
@@ -93,15 +99,18 @@ export async function GET(request: Request) {
return NextResponse.json({ data: {} }, { status: 200 })
}
// Decrypt the variables for client-side use
const encryptedVariables = result[0].variables as Record<string, string>
const decryptedVariables: Record<string, EnvironmentVariable> = {}
// Decrypt each variable
for (const [key, encryptedValue] of Object.entries(encryptedVariables)) {
try {
const { decrypted } = await decryptSecret(encryptedValue)
decryptedVariables[key] = { key, value: decrypted }
} catch (error) {
logger.error(`[${requestId}] Error decrypting variable ${key}`, error)
// If decryption fails, provide a placeholder
decryptedVariables[key] = { key, value: '' }
}
}

View File

@@ -0,0 +1,225 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEnvironmentVariableKeys } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { decryptSecret, encryptSecret } from '@/lib/utils'
import { getUserId } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { environment } from '@/db/schema'
const logger = createLogger('EnvironmentVariablesAPI')
// Schema for environment variable updates
const EnvVarSchema = z.object({
variables: z.record(z.string()),
})
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// For GET requests, check for workflowId in query params
const { searchParams } = new URL(request.url)
const workflowId = searchParams.get('workflowId')
// Use dual authentication pattern like other copilot tools
const userId = await getUserId(requestId, workflowId || undefined)
if (!userId) {
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get only the variable names (keys), not values
const result = await getEnvironmentVariableKeys(userId)
return NextResponse.json(
{
success: true,
output: result,
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Environment variables fetch error`, error)
return NextResponse.json(
{
success: false,
error: error.message || 'Failed to get environment variables',
},
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const { workflowId, variables } = body
// Use dual authentication pattern like other copilot tools
const userId = await getUserId(requestId, workflowId)
if (!userId) {
logger.warn(`[${requestId}] Unauthorized environment variables set attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { variables: validatedVariables } = EnvVarSchema.parse({ variables })
// Get existing environment variables for this user
const existingData = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
// Start with existing encrypted variables or empty object
const existingEncryptedVariables =
(existingData[0]?.variables as Record<string, string>) || {}
// Determine which variables are new or changed by comparing with decrypted existing values
const variablesToEncrypt: Record<string, string> = {}
const addedVariables: string[] = []
const updatedVariables: string[] = []
for (const [key, newValue] of Object.entries(validatedVariables)) {
if (!(key in existingEncryptedVariables)) {
// New variable
variablesToEncrypt[key] = newValue
addedVariables.push(key)
} else {
// Check if the value has actually changed by decrypting the existing value
try {
const { decrypted: existingValue } = await decryptSecret(
existingEncryptedVariables[key]
)
if (existingValue !== newValue) {
// Value changed, needs re-encryption
variablesToEncrypt[key] = newValue
updatedVariables.push(key)
}
// If values are the same, keep the existing encrypted value
} catch (decryptError) {
// If we can't decrypt the existing value, treat as changed and re-encrypt
logger.warn(
`[${requestId}] Could not decrypt existing variable ${key}, re-encrypting`,
{
error: decryptError,
}
)
variablesToEncrypt[key] = newValue
updatedVariables.push(key)
}
}
}
// Only encrypt the variables that are new or changed
const newlyEncryptedVariables = await Object.entries(variablesToEncrypt).reduce(
async (accPromise, [key, value]) => {
const acc = await accPromise
const { encrypted } = await encryptSecret(value)
return { ...acc, [key]: encrypted }
},
Promise.resolve({})
)
// Merge existing encrypted variables with newly encrypted ones
const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables }
// Update or insert environment variables for user
await db
.insert(environment)
.values({
id: crypto.randomUUID(),
userId: userId,
variables: finalEncryptedVariables,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: {
variables: finalEncryptedVariables,
updatedAt: new Date(),
},
})
return NextResponse.json(
{
success: true,
output: {
message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`,
variableCount: Object.keys(validatedVariables).length,
variableNames: Object.keys(validatedVariables),
totalVariableCount: Object.keys(finalEncryptedVariables).length,
addedVariables,
updatedVariables,
},
},
{ status: 200 }
)
} catch (validationError) {
if (validationError instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid environment variables data`, {
errors: validationError.errors,
})
return NextResponse.json(
{ error: 'Invalid request data', details: validationError.errors },
{ status: 400 }
)
}
throw validationError
}
} catch (error: any) {
logger.error(`[${requestId}] Environment variables set error`, error)
return NextResponse.json(
{
success: false,
error: error.message || 'Failed to set environment variables',
},
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await request.json()
const { workflowId } = body
// Use dual authentication pattern like other copilot tools
const userId = await getUserId(requestId, workflowId)
if (!userId) {
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get only the variable names (keys), not values
const result = await getEnvironmentVariableKeys(userId)
return NextResponse.json(
{
success: true,
output: result,
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Environment variables fetch error`, error)
return NextResponse.json(
{
success: false,
error: error.message || 'Failed to get environment variables',
},
{ status: 500 }
)
}
}

View File

@@ -76,9 +76,11 @@ export async function POST(request: NextRequest) {
logger.info('File parse request received:', { filePath, fileType })
// Handle multiple files
if (Array.isArray(filePath)) {
const results = []
for (const path of filePath) {
// Skip empty or invalid paths
if (!path || (typeof path === 'string' && path.trim() === '')) {
results.push({
success: false,
@@ -89,10 +91,12 @@ export async function POST(request: NextRequest) {
}
const result = await parseFileSingle(path, fileType)
// Add processing time to metadata
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
// Transform each result to match expected frontend format
if (result.success) {
results.push({
success: true,
@@ -101,7 +105,7 @@ export async function POST(request: NextRequest) {
name: result.filePath.split('/').pop() || 'unknown',
fileType: result.metadata?.fileType || 'application/octet-stream',
size: result.metadata?.size || 0,
binary: false,
binary: false, // We only return text content
},
filePath: result.filePath,
})
@@ -116,12 +120,15 @@ export async function POST(request: NextRequest) {
})
}
// Handle single file
const result = await parseFileSingle(filePath, fileType)
// Add processing time to metadata
if (result.metadata) {
result.metadata.processingTime = Date.now() - startTime
}
// Transform single file result to match expected frontend format
if (result.success) {
return NextResponse.json({
success: true,
@@ -135,6 +142,8 @@ export async function POST(request: NextRequest) {
})
}
// Only return 500 for actual server errors, not file processing failures
// File processing failures (like file not found, parsing errors) should return 200 with success:false
return NextResponse.json(result)
} catch (error) {
logger.error('Error in file parse API:', error)
@@ -155,6 +164,7 @@ export async function POST(request: NextRequest) {
async function parseFileSingle(filePath: string, fileType?: string): Promise<ParseResult> {
logger.info('Parsing file:', filePath)
// Validate that filePath is not empty
if (!filePath || filePath.trim() === '') {
return {
success: false,
@@ -163,6 +173,7 @@ async function parseFileSingle(filePath: string, fileType?: string): Promise<Par
}
}
// Validate path for security before any processing
const pathValidation = validateFilePath(filePath)
if (!pathValidation.isValid) {
return {
@@ -172,40 +183,49 @@ async function parseFileSingle(filePath: string, fileType?: string): Promise<Par
}
}
// Check if this is an external URL
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return handleExternalUrl(filePath, fileType)
}
// Check if this is a cloud storage path (S3 or Blob)
const isS3Path = filePath.includes('/api/files/serve/s3/')
const isBlobPath = filePath.includes('/api/files/serve/blob/')
// Use cloud handler if it's a cloud path or we're in cloud mode
if (isS3Path || isBlobPath || isUsingCloudStorage()) {
return handleCloudFile(filePath, fileType)
}
// Use local handler for local files
return handleLocalFile(filePath, fileType)
}
/**
* Validate file path for security - prevents null byte injection and path traversal attacks
* Validate file path for security
*/
function validateFilePath(filePath: string): { isValid: boolean; error?: string } {
// Check for null bytes
if (filePath.includes('\0')) {
return { isValid: false, error: 'Invalid path: null byte detected' }
}
// Check for path traversal attempts
if (filePath.includes('..')) {
return { isValid: false, error: 'Access denied: path traversal detected' }
}
// Check for tilde characters (home directory access)
if (filePath.includes('~')) {
return { isValid: false, error: 'Invalid path: tilde character not allowed' }
}
// Check for absolute paths outside allowed directories
if (filePath.startsWith('/') && !filePath.startsWith('/api/files/serve/')) {
return { isValid: false, error: 'Path outside allowed directory' }
}
// Check for Windows absolute paths
if (/^[A-Za-z]:\\/.test(filePath)) {
return { isValid: false, error: 'Path outside allowed directory' }
}
@@ -240,10 +260,12 @@ async function handleExternalUrl(url: string, fileType?: string): Promise<ParseR
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
// Extract filename from URL
const urlPath = new URL(url).pathname
const filename = urlPath.split('/').pop() || 'download'
const extension = path.extname(filename).toLowerCase().substring(1)
// Process the file based on its content type
if (extension === 'pdf') {
return await handlePdfBuffer(buffer, filename, fileType, url)
}
@@ -254,6 +276,7 @@ async function handleExternalUrl(url: string, fileType?: string): Promise<ParseR
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
}
// For binary or unknown files
return handleGenericBuffer(buffer, filename, extension, fileType)
} catch (error) {
logger.error(`Error handling external URL ${url}:`, error)
@@ -266,29 +289,35 @@ async function handleExternalUrl(url: string, fileType?: string): Promise<ParseR
}
/**
* Handle file stored in cloud storage
* Handle file stored in cloud storage (S3 or Azure Blob)
*/
async function handleCloudFile(filePath: string, fileType?: string): Promise<ParseResult> {
try {
// Extract the cloud key from the path
let cloudKey: string
if (filePath.includes('/api/files/serve/s3/')) {
cloudKey = decodeURIComponent(filePath.split('/api/files/serve/s3/')[1])
} else if (filePath.includes('/api/files/serve/blob/')) {
cloudKey = decodeURIComponent(filePath.split('/api/files/serve/blob/')[1])
} else if (filePath.startsWith('/api/files/serve/')) {
// Backwards-compatibility: path like "/api/files/serve/<key>"
cloudKey = decodeURIComponent(filePath.substring('/api/files/serve/'.length))
} else {
// Assume raw key provided
cloudKey = filePath
}
logger.info('Extracted cloud key:', cloudKey)
// Download the file from cloud storage - this can throw for access errors
const fileBuffer = await downloadFile(cloudKey)
logger.info(`Downloaded file from cloud storage: ${cloudKey}, size: ${fileBuffer.length} bytes`)
// Extract the filename from the cloud key
const filename = cloudKey.split('/').pop() || cloudKey
const extension = path.extname(filename).toLowerCase().substring(1)
// Process the file based on its content type
if (extension === 'pdf') {
return await handlePdfBuffer(fileBuffer, filename, fileType, filePath)
}
@@ -296,19 +325,22 @@ async function handleCloudFile(filePath: string, fileType?: string): Promise<Par
return await handleCsvBuffer(fileBuffer, filename, fileType, filePath)
}
if (isSupportedFileType(extension)) {
// For other supported types that we have parsers for
return await handleGenericTextBuffer(fileBuffer, filename, extension, fileType, filePath)
}
// For binary or unknown files
return handleGenericBuffer(fileBuffer, filename, extension, fileType)
} catch (error) {
logger.error(`Error handling cloud file ${filePath}:`, error)
// For download/access errors, throw to trigger 500 response
// Check if this is a download/access error that should trigger a 500 response
const errorMessage = (error as Error).message
if (errorMessage.includes('Access denied') || errorMessage.includes('Forbidden')) {
// For access errors, throw to trigger 500 response
throw new Error(`Error accessing file from cloud storage: ${errorMessage}`)
}
// For other errors (parsing, processing), return success:false and an error message
// For other errors (parsing, processing), return success:false
return {
success: false,
error: `Error accessing file from cloud storage: ${errorMessage}`,
@@ -322,23 +354,28 @@ async function handleCloudFile(filePath: string, fileType?: string): Promise<Par
*/
async function handleLocalFile(filePath: string, fileType?: string): Promise<ParseResult> {
try {
// Extract filename from path
const filename = filePath.split('/').pop() || filePath
const fullPath = path.join(UPLOAD_DIR_SERVER, filename)
logger.info('Processing local file:', fullPath)
// Check if file exists
try {
await fsPromises.access(fullPath)
} catch {
throw new Error(`File not found: ${filename}`)
}
// Parse the file directly
const result = await parseFile(fullPath)
// Get file stats for metadata
const stats = await fsPromises.stat(fullPath)
const fileBuffer = await readFile(fullPath)
const hash = createHash('md5').update(fileBuffer).digest('hex')
// Extract file extension for type detection
const extension = path.extname(filename).toLowerCase().substring(1)
return {
@@ -349,7 +386,7 @@ async function handleLocalFile(filePath: string, fileType?: string): Promise<Par
fileType: fileType || getMimeType(extension),
size: stats.size,
hash,
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
@@ -388,14 +425,15 @@ async function handlePdfBuffer(
fileType: fileType || 'application/pdf',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
logger.error('Failed to parse PDF in memory:', error)
// Create fallback message for PDF parsing failure
const content = createPdfFailureMessage(
0,
0, // We can't determine page count without parsing
fileBuffer.length,
originalPath || filename,
(error as Error).message
@@ -409,7 +447,7 @@ async function handlePdfBuffer(
fileType: fileType || 'application/pdf',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
}
@@ -427,6 +465,7 @@ async function handleCsvBuffer(
try {
logger.info(`Parsing CSV in memory: ${filename}`)
// Use the parseBuffer function from our library
const { parseBuffer } = await import('@/lib/file-parsers')
const result = await parseBuffer(fileBuffer, 'csv')
@@ -438,7 +477,7 @@ async function handleCsvBuffer(
fileType: fileType || 'text/csv',
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
@@ -451,7 +490,7 @@ async function handleCsvBuffer(
fileType: 'text/csv',
size: 0,
hash: '',
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
}
@@ -470,6 +509,7 @@ async function handleGenericTextBuffer(
try {
logger.info(`Parsing text file in memory: ${filename}`)
// Try to use a specialized parser if available
try {
const { parseBuffer, isSupportedFileType } = await import('@/lib/file-parsers')
@@ -484,7 +524,7 @@ async function handleGenericTextBuffer(
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
}
@@ -492,6 +532,7 @@ async function handleGenericTextBuffer(
logger.warn('Specialized parser failed, falling back to generic parsing:', parserError)
}
// Fallback to generic text parsing
const content = fileBuffer.toString('utf-8')
return {
@@ -502,7 +543,7 @@ async function handleGenericTextBuffer(
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
} catch (error) {
@@ -515,7 +556,7 @@ async function handleGenericTextBuffer(
fileType: 'text/plain',
size: 0,
hash: '',
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
}
@@ -543,7 +584,7 @@ function handleGenericBuffer(
fileType: fileType || getMimeType(extension),
size: fileBuffer.length,
hash: createHash('md5').update(fileBuffer).digest('hex'),
processingTime: 0,
processingTime: 0, // Will be set by caller
},
}
}
@@ -553,11 +594,25 @@ function handleGenericBuffer(
*/
async function parseBufferAsPdf(buffer: Buffer) {
try {
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
const parser = new PdfParser()
logger.info('Using main PDF parser for buffer')
// Import parsers dynamically to avoid initialization issues in tests
// First try to use the main PDF parser
try {
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
const parser = new PdfParser()
logger.info('Using main PDF parser for buffer')
return await parser.parseBuffer(buffer)
if (parser.parseBuffer) {
return await parser.parseBuffer(buffer)
}
throw new Error('PDF parser does not support buffer parsing')
} catch (error) {
// Fallback to raw PDF parser
logger.warn('Main PDF parser failed, using raw parser for buffer:', error)
const { RawPdfParser } = await import('@/lib/file-parsers/raw-pdf-parser')
const rawParser = new RawPdfParser()
return await rawParser.parseBuffer(buffer)
}
} catch (error) {
throw new Error(`PDF parsing failed: ${(error as Error).message}`)
}
@@ -600,7 +655,7 @@ Please use a PDF viewer for best results.`
}
/**
* Create error message for PDF parsing failure and make it more readable
* Create error message for PDF parsing failure
*/
function createPdfFailureMessage(
pageCount: number,

View File

@@ -1,13 +1,7 @@
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
/**
* Tests for file presigned API route
*
* @vitest-environment node
*/
describe('/api/files/presigned', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -25,7 +19,7 @@ describe('/api/files/presigned', () => {
})
describe('POST', () => {
it('should return error when cloud storage is not enabled', async () => {
test('should return error when cloud storage is not enabled', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 's3',
@@ -45,7 +39,7 @@ describe('/api/files/presigned', () => {
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(data.directUploadSupported).toBe(false)

View File

@@ -32,14 +32,6 @@ describe('Function Execute API Route', () => {
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/execution/e2b', () => ({
executeInE2B: vi.fn().mockResolvedValue({
result: 'e2b success',
stdout: 'e2b output',
sandboxId: 'test-sandbox-id',
}),
}))
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
@@ -53,7 +45,6 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return "Hello World"',
timeout: 5000,
useLocalVM: true,
})
const { POST } = await import('@/app/api/function/execute/route')
@@ -83,7 +74,6 @@ describe('Function Execute API Route', () => {
it('should use default timeout when not provided', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
useLocalVM: true,
})
const { POST } = await import('@/app/api/function/execute/route')
@@ -103,7 +93,6 @@ describe('Function Execute API Route', () => {
it('should resolve environment variables with {{var_name}} syntax', async () => {
const req = createMockRequest('POST', {
code: 'return {{API_KEY}}',
useLocalVM: true,
envVars: {
API_KEY: 'secret-key-123',
},
@@ -119,7 +108,6 @@ describe('Function Execute API Route', () => {
it('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: {
email: { id: '123', subject: 'Test Email' },
},
@@ -135,7 +123,6 @@ describe('Function Execute API Route', () => {
it('should NOT treat email addresses as template variables', async () => {
const req = createMockRequest('POST', {
code: 'return "Email sent to user"',
useLocalVM: true,
params: {
email: {
from: 'Waleed Latif <waleed@sim.ai>',
@@ -154,7 +141,6 @@ describe('Function Execute API Route', () => {
it('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
useLocalVM: true,
params: {
validVar: 'hello',
another_valid: 'world',
@@ -192,7 +178,6 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: gmailData,
})
@@ -215,7 +200,6 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: complexEmailData,
})
@@ -230,7 +214,6 @@ describe('Function Execute API Route', () => {
it('should handle custom tool execution with direct parameter access', async () => {
const req = createMockRequest('POST', {
code: 'return location + " weather is sunny"',
useLocalVM: true,
params: {
location: 'San Francisco',
},
@@ -262,7 +245,6 @@ describe('Function Execute API Route', () => {
it('should handle timeout parameter', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
useLocalVM: true,
timeout: 10000,
})
@@ -280,7 +262,6 @@ describe('Function Execute API Route', () => {
it('should handle empty parameters object', async () => {
const req = createMockRequest('POST', {
code: 'return "no params"',
useLocalVM: true,
params: {},
})
@@ -314,7 +295,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;',
useLocalVM: true,
timeout: 5000,
})
@@ -358,7 +338,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = null;\nreturn obj.someMethod();',
useLocalVM: true,
timeout: 5000,
})
@@ -400,7 +379,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const x = 42;\nreturn undefinedVariable + x;',
useLocalVM: true,
timeout: 5000,
})
@@ -431,7 +409,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'return "test";',
useLocalVM: true,
timeout: 5000,
})
@@ -468,7 +445,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
useLocalVM: true,
timeout: 5000,
})
@@ -500,7 +476,6 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test"\n// Missing closing brace',
useLocalVM: true,
timeout: 5000,
})
@@ -521,7 +496,6 @@ SyntaxError: Invalid or unexpected token
// This tests the escapeRegExp function indirectly
const req = createMockRequest('POST', {
code: 'return {{special.chars+*?}}',
useLocalVM: true,
envVars: {
'special.chars+*?': 'escaped-value',
},
@@ -538,7 +512,6 @@ SyntaxError: Invalid or unexpected token
// Test with complex but not circular data first
const req = createMockRequest('POST', {
code: 'return <complexData>',
useLocalVM: true,
params: {
complexData: {
special: 'chars"with\'quotes',

View File

@@ -1,8 +1,5 @@
import { createContext, Script } from 'vm'
import { type NextRequest, NextResponse } from 'next/server'
import { env, isTruthy } from '@/lib/env'
import { executeInE2B } from '@/lib/execution/e2b'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
@@ -11,10 +8,6 @@ export const maxDuration = 60
const logger = createLogger('FunctionExecuteAPI')
// Constants for E2B code wrapping line counts
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__():'
/**
* Enhanced error information interface
*/
@@ -131,103 +124,6 @@ function extractEnhancedError(
return enhanced
}
/**
* Parse and format E2B error message
* Removes E2B-specific line references and adds correct user line numbers
*/
function formatE2BError(
errorMessage: string,
errorOutput: string,
language: CodeLanguage,
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
let userLine: number | undefined
let cleanErrorType = ''
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]
cleanErrorMsg = jsErrorMatch[2].trim()
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]
cleanErrorMsg = errorMatch[2]
.replace(/^[^:]+:\s*/, '') // Remove file path
.replace(/\s*\(\d+:\d+\)\s*$/, '') // Remove line:col at end
.trim()
} else {
cleanErrorMsg = firstLine
}
}
}
// 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')
// Clamp userLine to the actual user code range
const actualUserLine = Math.min(userLine, codeLines.length)
if (actualUserLine > 0 && actualUserLine <= codeLines.length) {
const lineContent = codeLines[actualUserLine - 1]?.trim()
if (lineContent) {
formattedError = `Line ${actualUserLine}: \`${lineContent}\` - ${finalErrorMsg}`
} else {
formattedError = `Line ${actualUserLine} - ${finalErrorMsg}`
}
}
}
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput }
}
/**
* Create a detailed error message for users
*/
@@ -546,8 +442,6 @@ export async function POST(req: NextRequest) {
code,
params = {},
timeout = 5000,
language = DEFAULT_CODE_LANGUAGE,
useLocalVM = false,
envVars = {},
blockData = {},
blockNameMapping = {},
@@ -580,163 +474,19 @@ export async function POST(req: NextRequest) {
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
const e2bEnabled = isTruthy(env.E2B_ENABLED)
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
const useE2B =
e2bEnabled &&
!useLocalVM &&
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
const executionMethod = 'vm' // Default execution method
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: e2bEnabled,
hasApiKey: Boolean(process.env.E2B_API_KEY),
language: lang,
})
let prologue = ''
const epilogue = ''
logger.info(`[${requestId}] Using VM for code execution`, {
hasEnvVars: Object.keys(envVars).length > 0,
hasWorkflowVariables: Object.keys(workflowVariables).length > 0,
})
if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += `const params = JSON.parse(${JSON.stringify(JSON.stringify(executionParams))});\n`
prologueLineCount++
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
prologueLineCount++
}
const wrapped = [
';(async () => {',
' try {',
' const __sim_result = await (async () => {',
` ${resolvedCode.split('\n').join('\n ')}`,
' })();',
" console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));",
' } catch (error) {',
' console.log(String((error && (error.stack || error.message)) || error));',
' throw error;',
' }',
'})();',
].join('\n')
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
result: e2bResult,
stdout: e2bStdout,
sandboxId,
error: e2bError,
} = await executeInE2B({
code: codeForE2B,
language: CodeLanguage.JavaScript,
timeoutMs: timeout,
})
const executionTime = Date.now() - execStart
stdout += e2bStdout
logger.info(`[${requestId}] E2B JS sandbox`, {
sandboxId,
stdoutPreview: e2bStdout?.slice(0, 200),
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
e2bStdout,
lang,
resolvedCode,
prologueLineCount
)
return NextResponse.json(
{
success: false,
error: formattedError,
output: { result: null, stdout: cleanedOutput, executionTime },
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
output: { result: e2bResult ?? null, stdout, executionTime },
})
}
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += 'import json\n'
prologueLineCount++
prologue += `params = json.loads(${JSON.stringify(JSON.stringify(executionParams))})\n`
prologueLineCount++
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
prologueLineCount++
}
const wrapped = [
'def __sim_main__():',
...resolvedCode.split('\n').map((l) => ` ${l}`),
'__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n')
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
result: e2bResult,
stdout: e2bStdout,
sandboxId,
error: e2bError,
} = await executeInE2B({
code: codeForE2B,
language: CodeLanguage.Python,
timeoutMs: timeout,
})
const executionTime = Date.now() - execStart
stdout += e2bStdout
logger.info(`[${requestId}] E2B Py sandbox`, {
sandboxId,
stdoutPreview: e2bStdout?.slice(0, 200),
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
e2bStdout,
lang,
resolvedCode,
prologueLineCount
)
return NextResponse.json(
{
success: false,
error: formattedError,
output: { result: null, stdout: cleanedOutput, executionTime },
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
output: { result: e2bResult ?? null, stdout, executionTime },
})
}
const executionMethod = 'vm'
// Create a secure context with console logging
const context = createContext({
params: executionParams,
environmentVariables: envVars,
...contextVariables,
fetch: (globalThis as any).fetch || require('node-fetch').default,
...contextVariables, // Add resolved variables directly to context
fetch: globalThis.fetch || require('node-fetch').default,
console: {
log: (...args: any[]) => {
const logMessage = `${args
@@ -754,17 +504,23 @@ export async function POST(req: NextRequest) {
},
})
// Calculate line offset for user code to provide accurate error reporting
const wrapperLines = ['(async () => {', ' try {']
// Add custom tool parameter declarations if needed
if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`)
})
}
userCodeStartLine = wrapperLines.length + 1
userCodeStartLine = wrapperLines.length + 1 // +1 because user code starts on next line
// Build the complete script with proper formatting for line numbers
const fullScript = [
...wrapperLines,
` ${resolvedCode.split('\n').join('\n ')}`,
` ${resolvedCode.split('\n').join('\n ')}`, // Indent user code
' } catch (error) {',
' console.error(error);',
' throw error;',
@@ -773,26 +529,33 @@ export async function POST(req: NextRequest) {
].join('\n')
const script = new Script(fullScript, {
filename: 'user-function.js',
lineOffset: 0,
columnOffset: 0,
filename: 'user-function.js', // This filename will appear in stack traces
lineOffset: 0, // Start line numbering from 0
columnOffset: 0, // Start column numbering from 0
})
const result = await script.runInContext(context, {
timeout,
displayErrors: true,
breakOnSigint: true,
breakOnSigint: true, // Allow breaking on SIGINT for better debugging
})
// }
const executionTime = Date.now() - startTime
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
executionTime,
})
return NextResponse.json({
const response = {
success: true,
output: { result, stdout, executionTime },
})
output: {
result,
stdout,
executionTime,
},
}
return NextResponse.json(response)
} catch (error: any) {
const executionTime = Date.now() - startTime
logger.error(`[${requestId}] Function execution failed`, {

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/consts'
import { SUPPORTED_FIELD_TYPES } from '@/lib/constants/knowledge'
import {
cleanupUnusedTagDefinitions,
createOrUpdateTagDefinitionsBulk,

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/consts'
import { SUPPORTED_FIELD_TYPES } from '@/lib/constants/knowledge'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { createLogger } from '@/lib/logs/console/logger'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'

View File

@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { TAG_SLOTS } from '@/lib/knowledge/consts'
import { TAG_SLOTS } from '@/lib/constants/knowledge'
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
import { createLogger } from '@/lib/logs/console/logger'
import { estimateTokenCount } from '@/lib/tokenization/estimators'

View File

@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
const logger = createLogger('LogsByExecutionIdAPI')
const logger = createLogger('FrozenCanvasAPI')
export async function GET(
_request: NextRequest,
@@ -13,7 +13,7 @@ export async function GET(
try {
const { executionId } = await params
logger.debug(`Fetching execution data for: ${executionId}`)
logger.debug(`Fetching frozen canvas data for execution: ${executionId}`)
// Get the workflow execution log to find the snapshot
const [workflowLog] = await db
@@ -50,14 +50,14 @@ export async function GET(
},
}
logger.debug(`Successfully fetched execution data for: ${executionId}`)
logger.debug(`Successfully fetched frozen canvas data for execution: ${executionId}`)
logger.debug(
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
return NextResponse.json(response)
} catch (error) {
logger.error('Error fetching execution data:', error)
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
logger.error('Error fetching frozen canvas data:', error)
return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 })
}
}

View File

@@ -1,198 +0,0 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import {
invitation,
member,
organization,
permissions,
user,
type WorkspaceInvitationStatus,
workspaceInvitation,
} from '@/db/schema'
const logger = createLogger('OrganizationInvitation')
// Get invitation details
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const orgInvitation = await db
.select()
.from(invitation)
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
.then((rows) => rows[0])
if (!orgInvitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
const org = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.then((rows) => rows[0])
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
return NextResponse.json({
invitation: orgInvitation,
organization: org,
})
} catch (error) {
logger.error('Error fetching organization invitation:', error)
return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 })
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { status } = await req.json()
if (!status || !['accepted', 'rejected', 'cancelled'].includes(status)) {
return NextResponse.json(
{ error: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' },
{ status: 400 }
)
}
const orgInvitation = await db
.select()
.from(invitation)
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
.then((rows) => rows[0])
if (!orgInvitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (orgInvitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
}
if (status === 'accepted') {
const userData = await db
.select()
.from(user)
.where(eq(user.id, session.user.id))
.then((rows) => rows[0])
if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) {
return NextResponse.json(
{ error: 'Email mismatch. You can only accept invitations sent to your email address.' },
{ status: 403 }
)
}
}
if (status === 'cancelled') {
const isAdmin = await db
.select()
.from(member)
.where(
and(
eq(member.organizationId, organizationId),
eq(member.userId, session.user.id),
eq(member.role, 'admin')
)
)
.then((rows) => rows.length > 0)
if (!isAdmin) {
return NextResponse.json(
{ error: 'Only organization admins can cancel invitations' },
{ status: 403 }
)
}
}
await db.transaction(async (tx) => {
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
if (status === 'accepted') {
await tx.insert(member).values({
id: randomUUID(),
userId: session.user.id,
organizationId,
role: orgInvitation.role,
createdAt: new Date(),
})
const linkedWorkspaceInvitations = await tx
.select()
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
)
)
for (const wsInvitation of linkedWorkspaceInvitations) {
await tx
.update(workspaceInvitation)
.set({
status: 'accepted' as WorkspaceInvitationStatus,
updatedAt: new Date(),
})
.where(eq(workspaceInvitation.id, wsInvitation.id))
await tx.insert(permissions).values({
id: randomUUID(),
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
userId: session.user.id,
permissionType: wsInvitation.permissions || 'read',
createdAt: new Date(),
updatedAt: new Date(),
})
}
} else if (status === 'cancelled') {
await tx
.update(workspaceInvitation)
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
}
})
logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,
userId: session.user.id,
email: orgInvitation.email,
})
return NextResponse.json({
success: true,
message: `Invitation ${status} successfully`,
invitation: { ...orgInvitation, status },
})
} catch (error) {
logger.error(`Error updating organization invitation:`, error)
return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 })
}
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getEmailSubject,
@@ -17,17 +17,9 @@ import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import {
invitation,
member,
organization,
user,
type WorkspaceInvitationStatus,
workspace,
workspaceInvitation,
} from '@/db/schema'
import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema'
const logger = createLogger('OrganizationInvitations')
const logger = createLogger('OrganizationInvitationsAPI')
interface WorkspaceInvitation {
workspaceId: string
@@ -48,6 +40,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id: organizationId } = await params
// Verify user has access to this organization
const memberEntry = await db
.select()
.from(member)
@@ -68,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Get all pending invitations for the organization
const invitations = await db
.select({
id: invitation.id,
@@ -124,8 +118,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const body = await request.json()
const { email, emails, role = 'member', workspaceInvitations } = body
// Handle single invitation vs batch
const invitationEmails = email ? [email] : emails
// Validate input
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
}
@@ -134,6 +130,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
}
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
@@ -151,6 +148,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Handle validation-only requests
if (validateOnly) {
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
@@ -169,6 +167,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
})
}
// Validate seat availability
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
if (!seatValidation.canInvite) {
@@ -186,6 +185,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
// Get organization details
const organizationEntry = await db
.select({ name: organization.name })
.from(organization)
@@ -196,6 +196,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
// Validate and normalize emails
const processedEmails = invitationEmails
.map((email: string) => {
const normalized = email.trim().toLowerCase()
@@ -208,9 +209,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
}
// Handle batch workspace invitations if provided
const validWorkspaceInvitations: WorkspaceInvitation[] = []
if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) {
for (const wsInvitation of workspaceInvitations) {
// Check if user has admin permission on this workspace
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
if (!canInvite) {
@@ -226,6 +229,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// Check for existing members
const existingMembers = await db
.select({ userEmail: user.email })
.from(member)
@@ -235,6 +239,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const existingEmails = existingMembers.map((m) => m.userEmail)
const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email))
// Check for existing pending invitations
const existingInvitations = await db
.select({ email: invitation.email })
.from(invitation)
@@ -260,6 +265,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
// Create invitations
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
const invitationsToCreate = emailsToInvite.map((email: string) => ({
id: randomUUID(),
@@ -274,10 +280,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
await db.insert(invitation).values(invitationsToCreate)
// Create workspace invitations if batch mode
const workspaceInvitationIds: string[] = []
if (isBatch && validWorkspaceInvitations.length > 0) {
for (const email of emailsToInvite) {
const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email)
for (const wsInvitation of validWorkspaceInvitations) {
const wsInvitationId = randomUUID()
const token = randomUUID()
@@ -291,7 +297,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
status: 'pending',
token,
permissions: wsInvitation.permission,
orgInvitationId: orgInviteForEmail?.id,
expiresAt,
createdAt: new Date(),
updatedAt: new Date(),
@@ -302,6 +307,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// Send invitation emails
const inviter = await db
.select({ name: user.name })
.from(user)
@@ -314,6 +320,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
let emailResult
if (isBatch && validWorkspaceInvitations.length > 0) {
// Get workspace details for batch email
const workspaceDetails = await db
.select({
id: workspace.id,
@@ -339,7 +346,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
organizationEntry[0]?.name || 'organization',
role,
workspaceInvitationsWithNames,
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
)
emailResult = await sendEmail({
@@ -352,7 +359,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`,
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
email
)
@@ -439,6 +446,7 @@ export async function DELETE(
)
}
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
@@ -456,9 +464,12 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Cancel the invitation
const result = await db
.update(invitation)
.set({ status: 'cancelled' })
.set({
status: 'cancelled',
})
.where(
and(
eq(invitation.id, invitationId),
@@ -475,23 +486,6 @@ export async function DELETE(
)
}
await db
.update(workspaceInvitation)
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
await db
.update(workspaceInvitation)
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
.where(
and(
isNull(workspaceInvitation.orgInvitationId),
eq(workspaceInvitation.email, result[0].email),
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus),
eq(workspaceInvitation.inviterId, session.user.id)
)
)
logger.info('Organization invitation cancelled', {
organizationId,
invitationId,

View File

@@ -260,7 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${invitationId}`,
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${invitationId}`,
normalizedEmail
)

View File

@@ -0,0 +1,373 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { invitation, member, permissions, user, workspaceInvitation } from '@/db/schema'
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
// Accept an organization invitation and any associated workspace invitations
export async function GET(req: NextRequest) {
const invitationId = req.nextUrl.searchParams.get('id')
if (!invitationId) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=missing-invitation-id',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const session = await getSession()
if (!session?.user?.id) {
// Redirect to login, user will be redirected back after login
return NextResponse.redirect(
new URL(
`/invite/organization?id=${invitationId}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
try {
// Find the organization invitation
const invitationResult = await db
.select()
.from(invitation)
.where(eq(invitation.id, invitationId))
.limit(1)
if (invitationResult.length === 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=invalid-invitation',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const orgInvitation = invitationResult[0]
// Check if invitation has expired
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
return NextResponse.redirect(
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
)
}
// Check if invitation is still pending
if (orgInvitation.status !== 'pending') {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=already-processed',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Get user data to check email verification status
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (userData.length === 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=user-not-found',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user's email is verified
if (!userData[0].emailVerified) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData[0].email}) before accepting invitations.`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Verify the email matches the current user
if (orgInvitation.email !== session.user.email) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${orgInvitation.email}, but you're logged in as ${userData[0].email}`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user is already a member of the organization
const existingMember = await db
.select()
.from(member)
.where(
and(
eq(member.organizationId, orgInvitation.organizationId),
eq(member.userId, session.user.id)
)
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=already-member',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Start transaction to accept both organization and workspace invitations
await db.transaction(async (tx) => {
// Accept organization invitation - add user as member
await tx.insert(member).values({
id: randomUUID(),
userId: session.user.id,
organizationId: orgInvitation.organizationId,
role: orgInvitation.role,
createdAt: new Date(),
})
// Mark organization invitation as accepted
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
// Find and accept any pending workspace invitations for the same email
const workspaceInvitations = await tx
.select()
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.email, orgInvitation.email),
eq(workspaceInvitation.status, 'pending')
)
)
for (const wsInvitation of workspaceInvitations) {
// Check if invitation hasn't expired
if (
wsInvitation.expiresAt &&
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
) {
// Check if user doesn't already have permissions on the workspace
const existingPermission = await tx
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, wsInvitation.workspaceId)
)
)
.limit(1)
if (existingPermission.length === 0) {
// Add workspace permissions
await tx.insert(permissions).values({
id: randomUUID(),
userId: session.user.id,
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
permissionType: wsInvitation.permissions,
createdAt: new Date(),
updatedAt: new Date(),
})
// Mark workspace invitation as accepted
await tx
.update(workspaceInvitation)
.set({ status: 'accepted' })
.where(eq(workspaceInvitation.id, wsInvitation.id))
logger.info('Accepted workspace invitation', {
workspaceId: wsInvitation.workspaceId,
userId: session.user.id,
permission: wsInvitation.permissions,
})
}
}
}
})
logger.info('Successfully accepted batch invitation', {
organizationId: orgInvitation.organizationId,
userId: session.user.id,
role: orgInvitation.role,
})
// Redirect to success page or main app
return NextResponse.redirect(
new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
)
} catch (error) {
logger.error('Failed to accept organization invitation', {
invitationId,
userId: session.user.id,
error,
})
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=server-error',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
}
// POST endpoint for programmatic acceptance (for API use)
export async function POST(req: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { invitationId } = await req.json()
if (!invitationId) {
return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 })
}
// Similar logic to GET but return JSON response
const invitationResult = await db
.select()
.from(invitation)
.where(eq(invitation.id, invitationId))
.limit(1)
if (invitationResult.length === 0) {
return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 })
}
const orgInvitation = invitationResult[0]
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
return NextResponse.json({ error: 'Invitation expired' }, { status: 400 })
}
if (orgInvitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
}
// Get user data to check email verification status
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (userData.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Check if user's email is verified
if (!userData[0].emailVerified) {
return NextResponse.json(
{
error: 'Email not verified',
message: `You must verify your email address (${userData[0].email}) before accepting invitations.`,
},
{ status: 403 }
)
}
if (orgInvitation.email !== session.user.email) {
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
}
// Check if user is already a member
const existingMember = await db
.select()
.from(member)
.where(
and(
eq(member.organizationId, orgInvitation.organizationId),
eq(member.userId, session.user.id)
)
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.json({ error: 'Already a member' }, { status: 400 })
}
let acceptedWorkspaces = 0
// Accept invitations in transaction
await db.transaction(async (tx) => {
// Accept organization invitation
await tx.insert(member).values({
id: randomUUID(),
userId: session.user.id,
organizationId: orgInvitation.organizationId,
role: orgInvitation.role,
createdAt: new Date(),
})
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
// Accept workspace invitations
const workspaceInvitations = await tx
.select()
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.email, orgInvitation.email),
eq(workspaceInvitation.status, 'pending')
)
)
for (const wsInvitation of workspaceInvitations) {
if (
wsInvitation.expiresAt &&
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
) {
const existingPermission = await tx
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, wsInvitation.workspaceId)
)
)
.limit(1)
if (existingPermission.length === 0) {
await tx.insert(permissions).values({
id: randomUUID(),
userId: session.user.id,
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
permissionType: wsInvitation.permissions,
createdAt: new Date(),
updatedAt: new Date(),
})
await tx
.update(workspaceInvitation)
.set({ status: 'accepted' })
.where(eq(workspaceInvitation.id, wsInvitation.id))
acceptedWorkspaces++
}
}
}
})
return NextResponse.json({
success: true,
message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`,
organizationId: orgInvitation.organizationId,
workspacesJoined: acceptedWorkspaces,
})
} catch (error) {
logger.error('Failed to accept organization invitation via API', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -23,6 +23,7 @@ describe('Scheduled Workflow Execution API Route', () => {
edges: sampleWorkflowState.edges || [],
loops: sampleWorkflowState.loops || {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -4,7 +4,6 @@ import { NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -18,7 +17,13 @@ import { decryptSecret } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { db } from '@/db'
import { subscription, userStats, workflow, workflowSchedule } from '@/db/schema'
import {
environment as environmentTable,
subscription,
userStats,
workflow,
workflowSchedule,
} from '@/db/schema'
import { Executor } from '@/executor'
import { Serializer } from '@/serializer'
import { RateLimiter } from '@/services/queue'
@@ -225,21 +230,27 @@ export async function GET() {
const edges = normalizedData.edges
const loops = normalizedData.loops
const parallels = normalizedData.parallels
const whiles = normalizedData.whiles
logger.info(
`[${requestId}] Loaded scheduled workflow ${schedule.workflowId} from normalized tables`
)
const mergedStates = mergeSubblockState(blocks)
// Retrieve environment variables with workspace precedence
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
workflowRecord.userId,
workflowRecord.workspaceId || undefined
)
const variables = EnvVarsSchema.parse({
...personalEncrypted,
...workspaceEncrypted,
})
// Retrieve environment variables for this user (if any).
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, workflowRecord.userId))
.limit(1)
if (!userEnv) {
logger.debug(
`[${requestId}] No environment record found for user ${workflowRecord.userId}. Proceeding with empty variables.`
)
}
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
const currentBlockStates = await Object.entries(mergedStates).reduce(
async (accPromise, [id, block]) => {
@@ -374,6 +385,7 @@ export async function GET() {
edges,
loops,
parallels,
whiles,
true // Enable validation during execution
)

View File

@@ -68,6 +68,7 @@ const CreateTemplateSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()),
parallels: z.record(z.any()),
whiles: z.record(z.any()),
}),
})

View File

@@ -1,114 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
authSource: z.string().optional(),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
collection: z.string().min(1, 'Collection name is required'),
filter: z
.union([z.string(), z.object({}).passthrough()])
.transform((val) => {
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
}
return val
})
.refine((val) => val && val.trim() !== '' && val !== '{}', {
message: 'Filter is required for MongoDB Delete',
}),
multi: z
.union([z.boolean(), z.string(), z.undefined()])
.optional()
.transform((val) => {
if (val === 'true' || val === true) return true
if (val === 'false' || val === false) return false
return false // Default to false
}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Deleting document(s) from ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi})`
)
const sanitizedCollection = sanitizeCollectionName(params.collection)
const filterValidation = validateFilter(params.filter)
if (!filterValidation.isValid) {
logger.warn(`[${requestId}] Filter validation failed: ${filterValidation.error}`)
return NextResponse.json(
{ error: `Filter validation failed: ${filterValidation.error}` },
{ status: 400 }
)
}
let filterDoc
try {
filterDoc = JSON.parse(params.filter)
} catch (error) {
logger.warn(`[${requestId}] Invalid filter JSON: ${params.filter}`)
return NextResponse.json({ error: 'Invalid JSON format in filter' }, { status: 400 })
}
client = await createMongoDBConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
authSource: params.authSource,
ssl: params.ssl,
})
const db = client.db(params.database)
const coll = db.collection(sanitizedCollection)
let result
if (params.multi) {
result = await coll.deleteMany(filterDoc)
} else {
result = await coll.deleteOne(filterDoc)
}
logger.info(`[${requestId}] Delete completed: ${result.deletedCount} documents deleted`)
return NextResponse.json({
message: `${result.deletedCount} documents deleted`,
deletedCount: result.deletedCount,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MongoDB delete failed:`, error)
return NextResponse.json({ error: `MongoDB delete failed: ${errorMessage}` }, { status: 500 })
} finally {
if (client) {
await client.close()
}
}
}

View File

@@ -1,102 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils'
const logger = createLogger('MongoDBExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
authSource: z.string().optional(),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
collection: z.string().min(1, 'Collection name is required'),
pipeline: z
.union([z.string(), z.array(z.object({}).passthrough())])
.transform((val) => {
if (Array.isArray(val)) {
return JSON.stringify(val)
}
return val
})
.refine((val) => val && val.trim() !== '', {
message: 'Pipeline is required',
}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing aggregation pipeline on ${params.host}:${params.port}/${params.database}.${params.collection}`
)
const sanitizedCollection = sanitizeCollectionName(params.collection)
const pipelineValidation = validatePipeline(params.pipeline)
if (!pipelineValidation.isValid) {
logger.warn(`[${requestId}] Pipeline validation failed: ${pipelineValidation.error}`)
return NextResponse.json(
{ error: `Pipeline validation failed: ${pipelineValidation.error}` },
{ status: 400 }
)
}
const pipelineDoc = JSON.parse(params.pipeline)
client = await createMongoDBConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
authSource: params.authSource,
ssl: params.ssl,
})
const db = client.db(params.database)
const coll = db.collection(sanitizedCollection)
const cursor = coll.aggregate(pipelineDoc)
const documents = await cursor.toArray()
logger.info(
`[${requestId}] Aggregation completed successfully, returned ${documents.length} documents`
)
return NextResponse.json({
message: `Aggregation completed, returned ${documents.length} documents`,
documents,
documentCount: documents.length,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MongoDB aggregation failed:`, error)
return NextResponse.json(
{ error: `MongoDB aggregation failed: ${errorMessage}` },
{ status: 500 }
)
} finally {
if (client) {
await client.close()
}
}
}

View File

@@ -1,98 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
const logger = createLogger('MongoDBInsertAPI')
const InsertSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
authSource: z.string().optional(),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
collection: z.string().min(1, 'Collection name is required'),
documents: z
.union([z.array(z.record(z.unknown())), z.string()])
.transform((val) => {
if (typeof val === 'string') {
try {
const parsed = JSON.parse(val)
return Array.isArray(parsed) ? parsed : [parsed]
} catch {
throw new Error('Invalid JSON in documents field')
}
}
return val
})
.refine((val) => Array.isArray(val) && val.length > 0, {
message: 'At least one document is required',
}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
try {
const body = await request.json()
const params = InsertSchema.parse(body)
logger.info(
`[${requestId}] Inserting ${params.documents.length} document(s) into ${params.host}:${params.port}/${params.database}.${params.collection}`
)
const sanitizedCollection = sanitizeCollectionName(params.collection)
client = await createMongoDBConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
authSource: params.authSource,
ssl: params.ssl,
})
const db = client.db(params.database)
const coll = db.collection(sanitizedCollection)
let result
if (params.documents.length === 1) {
result = await coll.insertOne(params.documents[0] as Record<string, unknown>)
logger.info(`[${requestId}] Single document inserted successfully`)
return NextResponse.json({
message: 'Document inserted successfully',
insertedId: result.insertedId.toString(),
documentCount: 1,
})
}
result = await coll.insertMany(params.documents as Record<string, unknown>[])
const insertedCount = Object.keys(result.insertedIds).length
logger.info(`[${requestId}] ${insertedCount} documents inserted successfully`)
return NextResponse.json({
message: `${insertedCount} documents inserted successfully`,
insertedIds: Object.values(result.insertedIds).map((id) => id.toString()),
documentCount: insertedCount,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MongoDB insert failed:`, error)
return NextResponse.json({ error: `MongoDB insert failed: ${errorMessage}` }, { status: 500 })
} finally {
if (client) {
await client.close()
}
}
}

View File

@@ -1,136 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
authSource: z.string().optional(),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
collection: z.string().min(1, 'Collection name is required'),
query: z
.union([z.string(), z.object({}).passthrough()])
.optional()
.default('{}')
.transform((val) => {
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
}
return val || '{}'
}),
limit: z
.union([z.coerce.number().int().positive(), z.literal(''), z.undefined()])
.optional()
.transform((val) => {
if (val === '' || val === undefined || val === null) {
return 100
}
return val
}),
sort: z
.union([z.string(), z.object({}).passthrough(), z.null()])
.optional()
.transform((val) => {
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
}
return val
}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing MongoDB query on ${params.host}:${params.port}/${params.database}.${params.collection}`
)
const sanitizedCollection = sanitizeCollectionName(params.collection)
let filter = {}
if (params.query?.trim()) {
const validation = validateFilter(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Filter validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Filter validation failed: ${validation.error}` },
{ status: 400 }
)
}
filter = JSON.parse(params.query)
}
let sortCriteria = {}
if (params.sort?.trim()) {
try {
sortCriteria = JSON.parse(params.sort)
} catch (error) {
logger.warn(`[${requestId}] Invalid sort JSON: ${params.sort}`)
return NextResponse.json({ error: 'Invalid JSON format in sort criteria' }, { status: 400 })
}
}
client = await createMongoDBConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
authSource: params.authSource,
ssl: params.ssl,
})
const db = client.db(params.database)
const coll = db.collection(sanitizedCollection)
let cursor = coll.find(filter)
if (Object.keys(sortCriteria).length > 0) {
cursor = cursor.sort(sortCriteria)
}
const limit = params.limit || 100
cursor = cursor.limit(limit)
const documents = await cursor.toArray()
logger.info(
`[${requestId}] Query executed successfully, returned ${documents.length} documents`
)
return NextResponse.json({
message: `Found ${documents.length} documents`,
documents,
documentCount: documents.length,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MongoDB query failed:`, error)
return NextResponse.json({ error: `MongoDB query failed: ${errorMessage}` }, { status: 500 })
} finally {
if (client) {
await client.close()
}
}
}

View File

@@ -1,143 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
authSource: z.string().optional(),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
collection: z.string().min(1, 'Collection name is required'),
filter: z
.union([z.string(), z.object({}).passthrough()])
.transform((val) => {
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
}
return val
})
.refine((val) => val && val.trim() !== '' && val !== '{}', {
message: 'Filter is required for MongoDB Update',
}),
update: z
.union([z.string(), z.object({}).passthrough()])
.transform((val) => {
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
}
return val
})
.refine((val) => val && val.trim() !== '', {
message: 'Update is required',
}),
upsert: z
.union([z.boolean(), z.string(), z.undefined()])
.optional()
.transform((val) => {
if (val === 'true' || val === true) return true
if (val === 'false' || val === false) return false
return false
}),
multi: z
.union([z.boolean(), z.string(), z.undefined()])
.optional()
.transform((val) => {
if (val === 'true' || val === true) return true
if (val === 'false' || val === false) return false
return false
}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Updating document(s) in ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi}, upsert: ${params.upsert})`
)
const sanitizedCollection = sanitizeCollectionName(params.collection)
const filterValidation = validateFilter(params.filter)
if (!filterValidation.isValid) {
logger.warn(`[${requestId}] Filter validation failed: ${filterValidation.error}`)
return NextResponse.json(
{ error: `Filter validation failed: ${filterValidation.error}` },
{ status: 400 }
)
}
let filterDoc
let updateDoc
try {
filterDoc = JSON.parse(params.filter)
updateDoc = JSON.parse(params.update)
} catch (error) {
logger.warn(`[${requestId}] Invalid JSON in filter or update`)
return NextResponse.json(
{ error: 'Invalid JSON format in filter or update' },
{ status: 400 }
)
}
client = await createMongoDBConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
authSource: params.authSource,
ssl: params.ssl,
})
const db = client.db(params.database)
const coll = db.collection(sanitizedCollection)
let result
if (params.multi) {
result = await coll.updateMany(filterDoc, updateDoc, { upsert: params.upsert })
} else {
result = await coll.updateOne(filterDoc, updateDoc, { upsert: params.upsert })
}
logger.info(
`[${requestId}] Update completed: ${result.modifiedCount} modified, ${result.matchedCount} matched${result.upsertedCount ? `, ${result.upsertedCount} upserted` : ''}`
)
return NextResponse.json({
message: `${result.modifiedCount} documents updated${result.upsertedCount ? `, ${result.upsertedCount} documents upserted` : ''}`,
matchedCount: result.matchedCount,
modifiedCount: result.modifiedCount,
documentCount: result.modifiedCount + (result.upsertedCount || 0),
...(result.upsertedId && { insertedId: result.upsertedId.toString() }),
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MongoDB update failed:`, error)
return NextResponse.json({ error: `MongoDB update failed: ${errorMessage}` }, { status: 500 })
} finally {
if (client) {
await client.close()
}
}
}

View File

@@ -1,123 +0,0 @@
import { MongoClient } from 'mongodb'
import type { MongoDBConnectionConfig } from '@/tools/mongodb/types'
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
const credentials =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
: ''
const queryParams = new URLSearchParams()
if (config.authSource) {
queryParams.append('authSource', config.authSource)
}
if (config.ssl === 'required') {
queryParams.append('ssl', 'true')
}
const queryString = queryParams.toString()
const uri = `mongodb://${credentials}${config.host}:${config.port}/${config.database}${queryString ? `?${queryString}` : ''}`
const client = new MongoClient(uri, {
connectTimeoutMS: 10000,
socketTimeoutMS: 10000,
maxPoolSize: 1,
})
await client.connect()
return client
}
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
try {
const parsed = JSON.parse(filter)
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
const checkForDangerousOps = (obj: any): boolean => {
if (typeof obj !== 'object' || obj === null) return false
for (const key of Object.keys(obj)) {
if (dangerousOperators.includes(key)) return true
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
}
return false
}
if (checkForDangerousOps(parsed)) {
return {
isValid: false,
error: 'Filter contains potentially dangerous operators',
}
}
return { isValid: true }
} catch (error) {
return {
isValid: false,
error: 'Invalid JSON format in filter',
}
}
}
export function validatePipeline(pipeline: string): { isValid: boolean; error?: string } {
try {
const parsed = JSON.parse(pipeline)
if (!Array.isArray(parsed)) {
return {
isValid: false,
error: 'Pipeline must be an array',
}
}
const dangerousOperators = [
'$where',
'$function',
'$accumulator',
'$let',
'$merge',
'$out',
'$currentOp',
'$listSessions',
'$listLocalSessions',
]
const checkPipelineStage = (stage: any): boolean => {
if (typeof stage !== 'object' || stage === null) return false
for (const key of Object.keys(stage)) {
if (dangerousOperators.includes(key)) return true
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
}
return false
}
for (const stage of parsed) {
if (checkPipelineStage(stage)) {
return {
isValid: false,
error: 'Pipeline contains potentially dangerous operators',
}
}
}
return { isValid: true }
} catch (error) {
return {
isValid: false,
error: 'Invalid JSON format in pipeline',
}
}
}
export function sanitizeCollectionName(name: string): string {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(
'Invalid collection name. Must start with letter or underscore and contain only letters, numbers, and underscores.'
)
}
return name
}

View File

@@ -1,68 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildDeleteQuery(params.table, params.where)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
return NextResponse.json({
message: `Data deleted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL delete failed:`, error)
return NextResponse.json({ error: `MySQL delete failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,75 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
)
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(connection, params.query)
logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`)
return NextResponse.json({
message: `SQL executed successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL execute failed:`, error)
return NextResponse.json({ error: `MySQL execute failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,89 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLInsertAPI')
const InsertSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error'
throw new Error(
`Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...`
)
}
}),
]),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = InsertSchema.parse(body)
logger.info(
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildInsertQuery(params.table, params.data)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
return NextResponse.json({
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL insert failed:`, error)
return NextResponse.json({ error: `MySQL insert failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,75 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing MySQL query on ${params.host}:${params.port}/${params.database}`
)
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(connection, params.query)
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
return NextResponse.json({
message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL query failed:`, error)
return NextResponse.json({ error: `MySQL query failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,87 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
throw new Error('Invalid JSON format in data field')
}
}),
]),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const connection = await createMySQLConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const { query, values } = buildUpdateQuery(params.table, params.data, params.where)
const result = await executeQuery(connection, query, values)
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
return NextResponse.json({
message: `Data updated successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await connection.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] MySQL update failed:`, error)
return NextResponse.json({ error: `MySQL update failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,175 +0,0 @@
import mysql from 'mysql2/promise'
export interface MySQLConnectionConfig {
host: string
port: number
database: string
username: string
password: string
ssl?: 'disabled' | 'required' | 'preferred'
}
export async function createMySQLConnection(config: MySQLConnectionConfig) {
const connectionConfig: mysql.ConnectionOptions = {
host: config.host,
port: config.port,
database: config.database,
user: config.username,
password: config.password,
}
if (config.ssl === 'disabled') {
// Don't set ssl property at all to disable SSL
} else if (config.ssl === 'required') {
connectionConfig.ssl = { rejectUnauthorized: true }
} else if (config.ssl === 'preferred') {
connectionConfig.ssl = { rejectUnauthorized: false }
}
return mysql.createConnection(connectionConfig)
}
export async function executeQuery(
connection: mysql.Connection,
query: string,
values?: unknown[]
) {
const [rows, fields] = await connection.execute(query, values)
if (Array.isArray(rows)) {
return {
rows: rows as unknown[],
rowCount: rows.length,
fields,
}
}
return {
rows: [],
rowCount: (rows as mysql.ResultSetHeader).affectedRows || 0,
fields,
}
}
export function validateQuery(query: string): { isValid: boolean; error?: string } {
const trimmedQuery = query.trim().toLowerCase()
const dangerousPatterns = [
/drop\s+database/i,
/drop\s+schema/i,
/drop\s+user/i,
/create\s+user/i,
/grant\s+/i,
/revoke\s+/i,
/alter\s+user/i,
/set\s+global/i,
/set\s+session/i,
/load\s+data/i,
/into\s+outfile/i,
/into\s+dumpfile/i,
/load_file\s*\(/i,
/system\s+/i,
/exec\s+/i,
/execute\s+immediate/i,
/xp_cmdshell/i,
/sp_configure/i,
/information_schema\.tables/i,
/mysql\.user/i,
/mysql\.db/i,
/mysql\.host/i,
/performance_schema/i,
/sys\./i,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(query)) {
return {
isValid: false,
error: `Query contains potentially dangerous operation: ${pattern.source}`,
}
}
}
const allowedStatements = /^(select|insert|update|delete|with|show|describe|explain)\s+/i
if (!allowedStatements.test(trimmedQuery)) {
return {
isValid: false,
error:
'Only SELECT, INSERT, UPDATE, DELETE, WITH, SHOW, DESCRIBE, and EXPLAIN statements are allowed',
}
}
return { isValid: true }
}
export function buildInsertQuery(table: string, data: Record<string, unknown>) {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const values = Object.values(data)
const placeholders = columns.map(() => '?').join(', ')
const query = `INSERT INTO ${sanitizedTable} (${columns.map(sanitizeIdentifier).join(', ')}) VALUES (${placeholders})`
return { query, values }
}
export function buildUpdateQuery(table: string, data: Record<string, unknown>, where: string) {
validateWhereClause(where)
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const values = Object.values(data)
const setClause = columns.map((col) => `${sanitizeIdentifier(col)} = ?`).join(', ')
const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where}`
return { query, values }
}
export function buildDeleteQuery(table: string, where: string) {
validateWhereClause(where)
const sanitizedTable = sanitizeIdentifier(table)
const query = `DELETE FROM ${sanitizedTable} WHERE ${where}`
return { query, values: [] }
}
function validateWhereClause(where: string): void {
const dangerousPatterns = [
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
/union\s+select/i,
/into\s+outfile/i,
/load_file/i,
/--/,
/\/\*/,
/\*\//,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(where)) {
throw new Error('WHERE clause contains potentially dangerous operation')
}
}
}
export function sanitizeIdentifier(identifier: string): string {
if (identifier.includes('.')) {
const parts = identifier.split('.')
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
}
return sanitizeSingleIdentifier(identifier)
}
function sanitizeSingleIdentifier(identifier: string): string {
const cleaned = identifier.replace(/`/g, '')
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) {
throw new Error(
`Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.`
)
}
return `\`${cleaned}\``
}

View File

@@ -1,70 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeDelete(sql, params.table, params.where)
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
return NextResponse.json({
message: `Data deleted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await sql.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL delete failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL delete failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -1,82 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
createPostgresConnection,
executeQuery,
validateQuery,
} from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
)
const validation = validateQuery(params.query)
if (!validation.isValid) {
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(sql, params.query)
logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`)
return NextResponse.json({
message: `SQL executed successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await sql.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL execute failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL execute failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -1,92 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLInsertAPI')
const InsertSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error'
throw new Error(
`Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...`
)
}
}),
]),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = InsertSchema.parse(body)
logger.info(
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeInsert(sql, params.table, params.data)
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
return NextResponse.json({
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await sql.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL insert failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL insert failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -1,66 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
query: z.string().min(1, 'Query is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeQuery(sql, params.query)
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
return NextResponse.json({
message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await sql.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL query failed:`, error)
return NextResponse.json({ error: `PostgreSQL query failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -1,89 +0,0 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
table: z.string().min(1, 'Table name is required'),
data: z.union([
z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'),
z
.string()
.min(1)
.transform((str) => {
try {
const parsed = JSON.parse(str)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Data must be a JSON object')
}
return parsed
} catch (e) {
throw new Error('Invalid JSON format in data field')
}
}),
]),
where: z.string().min(1, 'WHERE clause is required'),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
ssl: params.ssl,
})
try {
const result = await executeUpdate(sql, params.table, params.data, params.where)
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
return NextResponse.json({
message: `Data updated successfully. ${result.rowCount} row(s) affected.`,
rows: result.rows,
rowCount: result.rowCount,
})
} finally {
await sql.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] PostgreSQL update failed:`, error)
return NextResponse.json(
{ error: `PostgreSQL update failed: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -1,194 +0,0 @@
import postgres from 'postgres'
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
export function createPostgresConnection(config: PostgresConnectionConfig) {
const sslConfig =
config.ssl === 'disabled'
? false
: config.ssl === 'required'
? 'require'
: config.ssl === 'preferred'
? 'prefer'
: 'require'
const sql = postgres({
host: config.host,
port: config.port,
database: config.database,
username: config.username,
password: config.password,
ssl: sslConfig,
connect_timeout: 10, // 10 seconds
idle_timeout: 20, // 20 seconds
max_lifetime: 60 * 30, // 30 minutes
max: 1, // Single connection for tool usage
})
return sql
}
export async function executeQuery(
sql: any,
query: string,
params: unknown[] = []
): Promise<{ rows: unknown[]; rowCount: number }> {
const result = await sql.unsafe(query, params)
return {
rows: Array.isArray(result) ? result : [result],
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
}
}
export function validateQuery(query: string): { isValid: boolean; error?: string } {
const trimmedQuery = query.trim().toLowerCase()
// Block dangerous SQL operations
const dangerousPatterns = [
/drop\s+database/i,
/drop\s+schema/i,
/drop\s+user/i,
/create\s+user/i,
/create\s+role/i,
/grant\s+/i,
/revoke\s+/i,
/alter\s+user/i,
/alter\s+role/i,
/set\s+role/i,
/reset\s+role/i,
/copy\s+.*from/i,
/copy\s+.*to/i,
/lo_import/i,
/lo_export/i,
/pg_read_file/i,
/pg_write_file/i,
/pg_ls_dir/i,
/information_schema\.tables/i,
/pg_catalog/i,
/pg_user/i,
/pg_shadow/i,
/pg_roles/i,
/pg_authid/i,
/pg_stat_activity/i,
/dblink/i,
/\\\\copy/i,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(query)) {
return {
isValid: false,
error: `Query contains potentially dangerous operation: ${pattern.source}`,
}
}
}
const allowedStatements = /^(select|insert|update|delete|with|explain|analyze|show)\s+/i
if (!allowedStatements.test(trimmedQuery)) {
return {
isValid: false,
error:
'Only SELECT, INSERT, UPDATE, DELETE, WITH, EXPLAIN, ANALYZE, and SHOW statements are allowed',
}
}
return { isValid: true }
}
export function sanitizeIdentifier(identifier: string): string {
if (identifier.includes('.')) {
const parts = identifier.split('.')
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
}
return sanitizeSingleIdentifier(identifier)
}
function validateWhereClause(where: string): void {
const dangerousPatterns = [
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
/union\s+select/i,
/into\s+outfile/i,
/load_file/i,
/--/,
/\/\*/,
/\*\//,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(where)) {
throw new Error('WHERE clause contains potentially dangerous operation')
}
}
}
function sanitizeSingleIdentifier(identifier: string): string {
const cleaned = identifier.replace(/"/g, '')
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) {
throw new Error(
`Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.`
)
}
return `"${cleaned}"`
}
export async function executeInsert(
sql: any,
table: string,
data: Record<string, unknown>
): Promise<{ rows: unknown[]; rowCount: number }> {
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
const placeholders = columns.map((_, index) => `$${index + 1}`)
const values = columns.map((col) => data[col])
const query = `INSERT INTO ${sanitizedTable} (${sanitizedColumns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
const result = await sql.unsafe(query, values)
return {
rows: Array.isArray(result) ? result : [result],
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
}
}
export async function executeUpdate(
sql: any,
table: string,
data: Record<string, unknown>,
where: string
): Promise<{ rows: unknown[]; rowCount: number }> {
validateWhereClause(where)
const sanitizedTable = sanitizeIdentifier(table)
const columns = Object.keys(data)
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
const setClause = sanitizedColumns.map((col, index) => `${col} = $${index + 1}`).join(', ')
const values = columns.map((col) => data[col])
const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where} RETURNING *`
const result = await sql.unsafe(query, values)
return {
rows: Array.isArray(result) ? result : [result],
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
}
}
export async function executeDelete(
sql: any,
table: string,
where: string
): Promise<{ rows: unknown[]; rowCount: number }> {
validateWhereClause(where)
const sanitizedTable = sanitizeIdentifier(table)
const query = `DELETE FROM ${sanitizedTable} WHERE ${where} RETURNING *`
const result = await sql.unsafe(query, [])
return {
rows: Array.isArray(result) ? result : [result],
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
}
}

View File

@@ -1,17 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
import {
getOrganizationBillingData,
isOrganizationOwnerOrAdmin,
} from '@/lib/billing/core/organization'
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
import { createLogger } from '@/lib/logs/console/logger'
import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
const logger = createLogger('UnifiedUsageAPI')
const logger = createLogger('UnifiedUsageLimitsAPI')
/**
* Unified Usage Endpoint
* GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id>
* Unified Usage Limits Endpoint
* GET/PUT /api/usage-limits?context=user|organization&userId=<id>&organizationId=<id>
*
*/
export async function GET(request: NextRequest) {
@@ -27,6 +25,7 @@ export async function GET(request: NextRequest) {
const userId = searchParams.get('userId') || session.user.id
const organizationId = searchParams.get('organizationId')
// Validate context
if (!['user', 'organization'].includes(context)) {
return NextResponse.json(
{ error: 'Invalid context. Must be "user" or "organization"' },
@@ -34,6 +33,7 @@ export async function GET(request: NextRequest) {
)
}
// For user context, ensure they can only view their own info
if (context === 'user' && userId !== session.user.id) {
return NextResponse.json(
{ error: "Cannot view other users' usage information" },
@@ -41,6 +41,7 @@ export async function GET(request: NextRequest) {
)
}
// Get usage limit info
if (context === 'organization') {
if (!organizationId) {
return NextResponse.json(
@@ -106,8 +107,10 @@ export async function PUT(request: NextRequest) {
}
if (context === 'user') {
// Update user's own usage limit
await updateUserUsageLimit(userId, limit)
} else if (context === 'organization') {
// context === 'organization'
if (!organizationId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=organization' },
@@ -120,7 +123,10 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
}
const { updateOrganizationUsageLimit } = await import('@/lib/billing/core/organization')
// Use the dedicated function to update org usage limit
const { updateOrganizationUsageLimit } = await import(
'@/lib/billing/core/organization-billing'
)
const result = await updateOrganizationUsageLimit(organizationId, limit)
if (!result.success) {
@@ -131,6 +137,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ success: true, context, userId, organizationId, data: updated })
}
// Return updated limit info
const updatedInfo = await getUserUsageLimitInfo(userId)
return NextResponse.json({

View File

@@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('UsageCheckAPI')
export async function GET(_request: NextRequest) {
const session = await getSession()
try {
const userId = session?.user?.id
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const result = await checkServerSideUsageLimits(userId)
// Normalize to client usage shape
return NextResponse.json({
success: true,
data: {
percentUsed:
result.limit > 0
? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100)
: 0,
isWarning:
result.limit > 0
? (result.currentUsage / result.limit) * 100 >= 80 &&
(result.currentUsage / result.limit) * 100 < 100
: false,
isExceeded: result.isExceeded,
currentUsage: result.currentUsage,
limit: result.limit,
message: result.message,
},
})
} catch (error) {
logger.error('Failed usage check', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -11,12 +11,15 @@ const logger = createLogger('RateLimitAPI')
export async function GET(request: NextRequest) {
try {
// Try session auth first (for web UI)
const session = await getSession()
let authenticatedUserId: string | null = session?.user?.id || null
// If no session, check for API key auth
if (!authenticatedUserId) {
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
// Verify API key
const [apiKeyRecord] = await db
.select({ userId: apiKeyTable.userId })
.from(apiKeyTable)
@@ -33,6 +36,7 @@ export async function GET(request: NextRequest) {
return createErrorResponse('Authentication required', 401)
}
// Get user subscription
const [subscriptionRecord] = await db
.select({ plan: subscription.plan })
.from(subscription)

View File

@@ -1,12 +1,7 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import OpenAI, { AzureOpenAI } from 'openai'
import { env } from '@/lib/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userStats, workflow } from '@/db/schema'
import { getModelPricing } from '@/providers/utils'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -52,9 +47,9 @@ interface RequestBody {
systemPrompt?: string
stream?: boolean
history?: ChatMessage[]
workflowId?: string
}
// Helper: safe stringify for error payloads that may include circular structures
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value)
@@ -63,80 +58,6 @@ function safeStringify(value: unknown): string {
}
}
async function updateUserStatsForWand(
workflowId: string,
usage: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
},
requestId: string
): Promise<void> {
if (!isBillingEnabled) {
logger.debug(`[${requestId}] Billing is disabled, skipping wand usage cost update`)
return
}
if (!usage.total_tokens || usage.total_tokens <= 0) {
logger.debug(`[${requestId}] No tokens to update in user stats`)
return
}
try {
const [workflowRecord] = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord?.userId) {
logger.warn(
`[${requestId}] No user found for workflow ${workflowId}, cannot update user stats`
)
return
}
const userId = workflowRecord.userId
const totalTokens = usage.total_tokens || 0
const promptTokens = usage.prompt_tokens || 0
const completionTokens = usage.completion_tokens || 0
const modelName = useWandAzure ? wandModelName : 'gpt-4o'
const pricing = getModelPricing(modelName)
const costMultiplier = getCostMultiplier()
let modelCost = 0
if (pricing) {
const inputCost = (promptTokens / 1000000) * pricing.input
const outputCost = (completionTokens / 1000000) * pricing.output
modelCost = inputCost + outputCost
} else {
modelCost = (promptTokens / 1000000) * 0.005 + (completionTokens / 1000000) * 0.015
}
const costToStore = modelCost * costMultiplier
await db
.update(userStats)
.set({
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
lastActive: new Date(),
})
.where(eq(userStats.userId, userId))
logger.debug(`[${requestId}] Updated user stats for wand usage`, {
userId,
tokensUsed: totalTokens,
costAdded: costToStore,
})
} catch (error) {
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
}
}
export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
logger.info(`[${requestId}] Received wand generation request`)
@@ -152,7 +73,7 @@ export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as RequestBody
const { prompt, systemPrompt, stream = false, history = [], workflowId } = body
const { prompt, systemPrompt, stream = false, history = [] } = body
if (!prompt) {
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
@@ -162,14 +83,18 @@ export async function POST(req: NextRequest) {
)
}
// Use provided system prompt or default
const finalSystemPrompt =
systemPrompt ||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
// Prepare messages for OpenAI API
const messages: ChatMessage[] = [{ role: 'system', content: finalSystemPrompt }]
// Add previous messages from history
messages.push(...history.filter((msg) => msg.role !== 'system'))
// Add the current user prompt
messages.push({ role: 'user', content: prompt })
logger.debug(
@@ -183,6 +108,7 @@ export async function POST(req: NextRequest) {
}
)
// For streaming responses
if (stream) {
try {
logger.debug(
@@ -193,6 +119,7 @@ export async function POST(req: NextRequest) {
`[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`
)
// Use native fetch for streaming to avoid OpenAI SDK issues with Node.js runtime
const apiUrl = useWandAzure
? `${azureEndpoint}/openai/deployments/${wandModelName}/chat/completions?api-version=${azureApiVersion}`
: 'https://api.openai.com/v1/chat/completions'
@@ -215,7 +142,7 @@ export async function POST(req: NextRequest) {
body: JSON.stringify({
model: useWandAzure ? wandModelName : 'gpt-4o',
messages: messages,
temperature: 0.2,
temperature: 0.3,
max_tokens: 10000,
stream: true,
stream_options: { include_usage: true },
@@ -234,6 +161,7 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Stream response received, starting processing`)
// Create a TransformStream to process the SSE data
const encoder = new TextEncoder()
const decoder = new TextDecoder()
@@ -248,7 +176,6 @@ export async function POST(req: NextRequest) {
try {
let buffer = ''
let chunkCount = 0
let finalUsage: any = null
while (true) {
const { done, value } = await reader.read()
@@ -260,10 +187,12 @@ export async function POST(req: NextRequest) {
break
}
// Decode the chunk
buffer += decoder.decode(value, { stream: true })
// Process complete SSE messages
const lines = buffer.split('\n')
buffer = lines.pop() || ''
buffer = lines.pop() || '' // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
@@ -288,22 +217,25 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Received first content chunk`)
}
// Forward the content
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
)
}
// Log usage if present
if (parsed.usage) {
finalUsage = parsed.usage
logger.info(
`[${requestId}] Received usage data: ${JSON.stringify(parsed.usage)}`
)
}
// Log progress periodically
if (chunkCount % 10 === 0) {
logger.debug(`[${requestId}] Processed ${chunkCount} chunks`)
}
} catch (parseError) {
// Skip invalid JSON lines
logger.debug(
`[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}`
)
@@ -313,10 +245,6 @@ export async function POST(req: NextRequest) {
}
logger.info(`[${requestId}] Wand generation streaming completed successfully`)
if (finalUsage && workflowId) {
await updateUserStatsForWand(workflowId, finalUsage, requestId)
}
} catch (streamError: any) {
logger.error(`[${requestId}] Streaming error`, {
name: streamError?.name,
@@ -324,6 +252,7 @@ export async function POST(req: NextRequest) {
stack: streamError?.stack,
})
// Send error to client
const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
controller.enqueue(encoder.encode(errorData))
controller.close()
@@ -333,12 +262,14 @@ export async function POST(req: NextRequest) {
},
})
// Return Response with proper headers for Node.js runtime
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Accel-Buffering': 'no', // Disable Nginx buffering
'Transfer-Encoding': 'chunked', // Important for Node.js runtime
},
})
} catch (error: any) {
@@ -363,6 +294,7 @@ export async function POST(req: NextRequest) {
}
}
// For non-streaming responses
const completion = await client.chat.completions.create({
model: useWandAzure ? wandModelName : 'gpt-4o',
messages: messages,
@@ -383,11 +315,6 @@ export async function POST(req: NextRequest) {
}
logger.info(`[${requestId}] Wand generation successful`)
if (completion.usage && workflowId) {
await updateUserStatsForWand(workflowId, completion.usage, requestId)
}
return NextResponse.json({ success: true, content: generatedContent })
} catch (error: any) {
logger.error(`[${requestId}] Wand generation failed`, {

View File

@@ -153,6 +153,7 @@ describe('Webhook Trigger API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -11,7 +11,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
export const dynamic = 'force-dynamic'
@@ -125,6 +129,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: currentWorkflowData.edges,
loops: currentWorkflowData.loops || {},
parallels: currentWorkflowData.parallels || {},
whiles: currentWorkflowData.whiles || {},
}
const autoLayoutOptions = {
@@ -166,6 +171,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
utilities: {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
},
},

View File

@@ -69,6 +69,7 @@ describe('Workflow Deployment API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -109,6 +109,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
whiles: normalizedData.whiles,
}
const { hasWorkflowChanged } = await import('@/lib/workflows/utils')
@@ -192,6 +193,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const blocksMap: Record<string, any> = {}
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
const whiles: Record<string, any> = {}
// Process blocks
blocks.forEach((block) => {
@@ -206,7 +208,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
})
// Process subflows (loops and parallels)
// Process subflows (loops, parallels, and whiles)
subflows.forEach((subflow) => {
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
@@ -225,6 +227,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
} else if (subflow.type === 'while') {
whiles[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterations: config.iterations || 1,
whileType: config.whileType || 'while',
}
}
})
@@ -244,6 +253,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: edgesArray,
loops,
parallels,
whiles,
lastSaved: Date.now(),
}

View File

@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
import type { LoopConfig, ParallelConfig, WhileConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateAPI')
@@ -209,16 +209,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
// Update block references in subflow config
let updatedConfig: LoopConfig | ParallelConfig = subflow.config as
let updatedConfig: LoopConfig | ParallelConfig | WhileConfig = subflow.config as
| LoopConfig
| ParallelConfig
| WhileConfig
if (subflow.config && typeof subflow.config === 'object') {
updatedConfig = JSON.parse(JSON.stringify(subflow.config)) as
| LoopConfig
| ParallelConfig
| WhileConfig
// Update the config ID to match the new subflow ID
;(updatedConfig as any).id = newSubflowId
// Update node references in config if they exist

View File

@@ -121,6 +121,7 @@ describe('Workflow Execution API Route', () => {
],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: false, // Changed to false since it's from deployed state
}),
}))
@@ -559,6 +560,7 @@ describe('Workflow Execution API Route', () => {
],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: false, // Changed to false since it's from deployed state
}),
}))

View File

@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -19,7 +18,7 @@ import {
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { subscription, userStats } from '@/db/schema'
import { environment as environmentTable, subscription, userStats } from '@/db/schema'
import { Executor } from '@/executor'
import { Serializer } from '@/serializer'
import {
@@ -65,12 +64,7 @@ class UsageLimitError extends Error {
}
}
async function executeWorkflow(
workflow: any,
requestId: string,
input?: any,
executingUserId?: string
): Promise<any> {
async function executeWorkflow(workflow: any, requestId: string, input?: any): Promise<any> {
const workflowId = workflow.id
const executionId = uuidv4()
@@ -121,27 +115,36 @@ async function executeWorkflow(
const deployedData = await loadDeployedWorkflowState(workflowId)
// Use deployed data as primary source for API executions
const { blocks, edges, loops, parallels } = deployedData
const { blocks, edges, loops, parallels, whiles } = deployedData
logger.info(`[${requestId}] Using deployed state for workflow execution: ${workflowId}`)
logger.debug(`[${requestId}] Deployed data loaded:`, {
blocksCount: Object.keys(blocks || {}).length,
edgesCount: (edges || []).length,
loopsCount: Object.keys(loops || {}).length,
parallelsCount: Object.keys(parallels || {}).length,
whilesCount: Object.keys(whiles || {}).length,
})
// Use the same execution flow as in scheduled executions
const mergedStates = mergeSubblockState(blocks)
// Load personal (for the executing user) and workspace env (workspace overrides personal)
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
executingUserId || workflow.userId,
workflow.workspaceId || undefined
)
const variables = EnvVarsSchema.parse({ ...personalEncrypted, ...workspaceEncrypted })
// Fetch the user's environment variables (if any)
const [userEnv] = await db
.select()
.from(environmentTable)
.where(eq(environmentTable.userId, workflow.userId))
.limit(1)
if (!userEnv) {
logger.debug(
`[${requestId}] No environment record found for user ${workflow.userId}. Proceeding with empty variables.`
)
}
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
await loggingSession.safeStart({
userId: executingUserId || workflow.userId,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
variables,
})
@@ -273,6 +276,7 @@ async function executeWorkflow(
edges,
loops,
parallels,
whiles,
true // Enable validation during execution
)
@@ -398,13 +402,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
const result = await executeWorkflow(
validation.workflow,
requestId,
undefined,
// Executing user (manual run): if session present, use that user for fallback
(await getSession())?.user?.id || undefined
)
const result = await executeWorkflow(validation.workflow, requestId, undefined)
// Check if the workflow execution contains a response block output
const hasResponseBlock = workflowHasResponseBlock(result)
@@ -593,12 +591,7 @@ export async function POST(
)
}
const result = await executeWorkflow(
validation.workflow,
requestId,
input,
authenticatedUserId
)
const result = await executeWorkflow(validation.workflow, requestId, input)
const hasResponseBlock = workflowHasResponseBlock(result)
if (hasResponseBlock) {

View File

@@ -52,6 +52,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edgesCount: deployedState.edges.length,
loopsCount: Object.keys(deployedState.loops || {}).length,
parallelsCount: Object.keys(deployedState.parallels || {}).length,
whilesCount: Object.keys(deployedState.whiles || {}).length,
})
// Save deployed state to normalized tables
@@ -60,6 +61,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
whiles: deployedState.whiles || {},
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,

View File

@@ -96,6 +96,7 @@ describe('Workflow By ID API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}
@@ -145,6 +146,7 @@ describe('Workflow By ID API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}
@@ -241,6 +243,7 @@ describe('Workflow By ID API Route', () => {
edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}

View File

@@ -126,6 +126,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edgesCount: normalizedData.edges.length,
loopsCount: Object.keys(normalizedData.loops).length,
parallelsCount: Object.keys(normalizedData.parallels).length,
whilesCount: Object.keys(normalizedData.whiles).length,
loops: normalizedData.loops,
})
@@ -141,6 +142,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
whiles: normalizedData.whiles,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,

View File

@@ -24,6 +24,7 @@ const BlockDataSchema = z.object({
count: z.number().optional(),
loopType: z.enum(['for', 'forEach']).optional(),
parallelType: z.enum(['collection', 'count']).optional(),
whileType: z.enum(['while', 'doWhile']).optional(),
type: z.string().optional(),
})
@@ -87,6 +88,13 @@ const ParallelSchema = z.object({
parallelType: z.enum(['count', 'collection']).optional(),
})
const WhileSchema = z.object({
id: z.string(),
nodes: z.array(z.string()),
iterations: z.number(),
whileType: z.enum(['while', 'doWhile']),
})
const DeploymentStatusSchema = z.object({
id: z.string(),
status: z.enum(['deploying', 'deployed', 'failed', 'stopping', 'stopped']),
@@ -99,6 +107,7 @@ const WorkflowStateSchema = z.object({
edges: z.array(EdgeSchema),
loops: z.record(LoopSchema).optional(),
parallels: z.record(ParallelSchema).optional(),
whiles: z.record(WhileSchema).optional(),
lastSaved: z.number().optional(),
isDeployed: z.boolean().optional(),
deployedAt: z.date().optional(),
@@ -197,6 +206,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
whiles: state.whiles || {},
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
@@ -231,6 +241,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
success: true,
blocksCount: Object.keys(filteredBlocks).length,
edgesCount: state.edges.length,
loopsCount: Object.keys(state.loops || {}).length,
parallelsCount: Object.keys(state.parallels || {}).length,
whilesCount: Object.keys(state.whiles || {}).length,
},
{ status: 200 }
)

View File

@@ -38,7 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const blocksMap: Record<string, any> = {}
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
const whiles: Record<string, any> = {}
// Process blocks
blocks.forEach((block) => {
blocksMap[block.id] = {
@@ -71,6 +71,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
} else if (subflow.type === 'while') {
whiles[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterations: config.iterations || 1,
whileType: config.whileType || 'while',
}
}
})
@@ -90,6 +97,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: edgesArray,
loops,
parallels,
whiles,
lastSaved: Date.now(),
}

View File

@@ -16,7 +16,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
@@ -80,6 +84,7 @@ async function createWorkflowCheckpoint(
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})
@@ -293,6 +298,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options: {
generateNewIds: false, // We'll handle ID generation manually for now
@@ -373,6 +379,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: [] as any[],
loops: {} as Record<string, any>,
parallels: {} as Record<string, any>,
whiles: {} as Record<string, any>,
lastSaved: Date.now(),
isDeployed: false,
deployedAt: undefined,
@@ -391,7 +398,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Get block configuration for proper setup
const blockConfig = getBlock(block.type)
if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) {
if (
!blockConfig &&
(block.type === 'loop' || block.type === 'parallel' || block.type === 'while')
) {
// Handle loop/parallel blocks (they don't have regular block configs)
// Preserve parentId if it exists (though loop/parallel shouldn't have parents)
const containerData = block.data || {}
@@ -414,7 +424,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
height: 0,
data: containerData,
}
logger.debug(`[${requestId}] Processed loop/parallel block: ${block.id} -> ${newId}`)
logger.debug(`[${requestId}] Processed loop/parallel/while block: ${block.id} -> ${newId}`)
} else if (blockConfig) {
// Handle regular blocks with proper configuration
const subBlocks: Record<string, any> = {}
@@ -545,14 +555,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Generate loop and parallel configurations
const loops = generateLoopBlocks(newWorkflowState.blocks)
const parallels = generateParallelBlocks(newWorkflowState.blocks)
const whiles = generateWhileBlocks(newWorkflowState.blocks)
newWorkflowState.loops = loops
newWorkflowState.parallels = parallels
newWorkflowState.whiles = whiles
logger.info(`[${requestId}] Generated workflow state`, {
blocksCount: Object.keys(newWorkflowState.blocks).length,
edgesCount: newWorkflowState.edges.length,
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
})
// Apply intelligent autolayout if requested
@@ -566,6 +579,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: newWorkflowState.edges,
loops: newWorkflowState.loops || {},
parallels: newWorkflowState.parallels || {},
whiles: newWorkflowState.whiles || {},
}
const autoLayoutOptions = {
@@ -608,6 +622,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})
@@ -685,6 +700,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edgesCount: newWorkflowState.edges.length,
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
},
errors: [],
warnings,

View File

@@ -1,12 +1,10 @@
import crypto from 'crypto'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { workflow, workflowBlocks, workspace } from '@/db/schema'
import { verifyWorkspaceMembership } from './utils'
import { workflow, workflowBlocks } from '@/db/schema'
const logger = createLogger('WorkflowAPI')
@@ -18,68 +16,6 @@ const CreateWorkflowSchema = z.object({
folderId: z.string().nullable().optional(),
})
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
export async function GET(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
const startTime = Date.now()
const url = new URL(request.url)
const workspaceId = url.searchParams.get('workspaceId')
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
if (workspaceId) {
const workspaceExists = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows.length > 0)
if (!workspaceExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)
return NextResponse.json(
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
{ status: 404 }
)
}
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
if (!userRole) {
logger.warn(
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
)
return NextResponse.json(
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
{ status: 403 }
)
}
}
let workflows
if (workspaceId) {
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
} else {
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
}
return NextResponse.json({ data: workflows }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
// POST /api/workflows - Create a new workflow
export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
@@ -100,7 +36,114 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
// Create initial state with start block
const initialState = {
blocks: {
[starterId]: {
id: starterId,
type: 'starter',
name: 'Start',
position: { x: 100, y: 100 },
subBlocks: {
startWorkflow: {
id: 'startWorkflow',
type: 'dropdown',
value: 'manual',
},
webhookPath: {
id: 'webhookPath',
type: 'short-input',
value: '',
},
webhookSecret: {
id: 'webhookSecret',
type: 'short-input',
value: '',
},
scheduleType: {
id: 'scheduleType',
type: 'dropdown',
value: 'daily',
},
minutesInterval: {
id: 'minutesInterval',
type: 'short-input',
value: '',
},
minutesStartingAt: {
id: 'minutesStartingAt',
type: 'short-input',
value: '',
},
hourlyMinute: {
id: 'hourlyMinute',
type: 'short-input',
value: '',
},
dailyTime: {
id: 'dailyTime',
type: 'short-input',
value: '',
},
weeklyDay: {
id: 'weeklyDay',
type: 'dropdown',
value: 'MON',
},
weeklyDayTime: {
id: 'weeklyDayTime',
type: 'short-input',
value: '',
},
monthlyDay: {
id: 'monthlyDay',
type: 'short-input',
value: '',
},
monthlyTime: {
id: 'monthlyTime',
type: 'short-input',
value: '',
},
cronExpression: {
id: 'cronExpression',
type: 'short-input',
value: '',
},
timezone: {
id: 'timezone',
type: 'dropdown',
value: 'UTC',
},
},
outputs: {
response: {
type: {
input: 'any',
},
},
},
enabled: true,
horizontalHandles: true,
isWide: false,
advancedMode: false,
triggerMode: false,
height: 95,
},
},
edges: [],
subflows: {},
variables: {},
metadata: {
version: '1.0.0',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
}
// Create the workflow and start block in a transaction
await db.transaction(async (tx) => {
// Create the workflow
await tx.insert(workflow).values({
id: workflowId,
userId: session.user.id,
@@ -120,6 +163,7 @@ export async function POST(req: NextRequest) {
marketplaceData: null,
})
// Insert the start block into workflow_blocks table
await tx.insert(workflowBlocks).values({
id: starterId,
workflowId: workflowId,

View File

@@ -0,0 +1,167 @@
import crypto from 'crypto'
import { and, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workspace } from '@/db/schema'
const logger = createLogger('WorkflowAPI')
/**
* Verifies user's workspace permissions using the permissions table
* @param userId User ID to check
* @param workspaceId Workspace ID to check
* @returns Permission type if user has access, null otherwise
*/
async function verifyWorkspaceMembership(
userId: string,
workspaceId: string
): Promise<string | null> {
try {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
return permission
} catch (error) {
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
return null
}
}
export async function GET(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
const startTime = Date.now()
const url = new URL(request.url)
const workspaceId = url.searchParams.get('workspaceId')
try {
// Get the session directly in the API route
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// If workspaceId is provided, verify it exists and user is a member
if (workspaceId) {
// Check workspace exists first
const workspaceExists = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows.length > 0)
if (!workspaceExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)
return NextResponse.json(
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
{ status: 404 }
)
}
// Verify the user is a member of the workspace using our optimized function
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
if (!userRole) {
logger.warn(
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
)
return NextResponse.json(
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
{ status: 403 }
)
}
// Migrate any orphaned workflows to this workspace (in background)
migrateOrphanedWorkflows(userId, workspaceId).catch((error) => {
logger.error(`[${requestId}] Error migrating orphaned workflows:`, error)
})
}
// Fetch workflows for the user
let workflows
if (workspaceId) {
// Filter by workspace ID only, not user ID
// This allows sharing workflows across workspace members
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
} else {
// Filter by user ID only, including workflows without workspace IDs
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
}
const elapsed = Date.now() - startTime
// Return the workflows
return NextResponse.json({ data: workflows }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
// Helper function to migrate orphaned workflows to a workspace
async function migrateOrphanedWorkflows(userId: string, workspaceId: string) {
try {
// Find workflows without workspace IDs for this user
const orphanedWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
if (orphanedWorkflows.length === 0) {
return // No orphaned workflows to migrate
}
logger.info(
`Migrating ${orphanedWorkflows.length} orphaned workflows to workspace ${workspaceId}`
)
// Update workflows in batch if possible
try {
// Batch update all orphaned workflows
await db
.update(workflow)
.set({
workspaceId: workspaceId,
updatedAt: new Date(),
})
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
logger.info(
`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`
)
} catch (batchError) {
logger.warn('Batch migration failed, falling back to individual updates:', batchError)
// Fallback to individual updates if batch update fails
for (const { id } of orphanedWorkflows) {
try {
await db
.update(workflow)
.set({
workspaceId: workspaceId,
updatedAt: new Date(),
})
.where(eq(workflow.id, id))
} catch (updateError) {
logger.error(`Failed to migrate workflow ${id}:`, updateError)
}
}
}
} catch (error) {
logger.error('Error migrating orphaned workflows:', error)
// Continue execution even if migration fails
}
}
// POST method removed - workflow operations now handled by:
// - POST /api/workflows (create)
// - DELETE /api/workflows/[id] (delete)
// - Socket.IO collaborative operations (real-time updates)

View File

@@ -1,8 +1,4 @@
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
const logger = createLogger('WorkflowUtils')
export function createErrorResponse(error: string, status: number, code?: string) {
return NextResponse.json(
@@ -17,23 +13,3 @@ export function createErrorResponse(error: string, status: number, code?: string
export function createSuccessResponse(data: any) {
return NextResponse.json(data)
}
/**
* Verifies user's workspace permissions using the permissions table
* @param userId User ID to check
* @param workspaceId Workspace ID to check
* @returns Permission type if user has access, null otherwise
*/
export async function verifyWorkspaceMembership(
userId: string,
workspaceId: string
): Promise<string | null> {
try {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
return permission
} catch (error) {
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
return null
}
}

View File

@@ -4,7 +4,11 @@ import { simAgentClient } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlAPI')
@@ -50,6 +54,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})

View File

@@ -10,7 +10,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlExportAPI')
@@ -144,6 +148,7 @@ export async function GET(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})

View File

@@ -1,232 +0,0 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { decryptSecret, encryptSecret } from '@/lib/utils'
import { db } from '@/db'
import { environment, workspace, workspaceEnvironment } from '@/db/schema'
const logger = createLogger('WorkspaceEnvironmentAPI')
const UpsertSchema = z.object({
variables: z.record(z.string()),
})
const DeleteSchema = z.object({
keys: z.array(z.string()).min(1),
})
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const workspaceId = (await params).id
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workspace env access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Validate workspace exists
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Require any permission to read
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Workspace env (encrypted)
const wsEnvRow = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
return NextResponse.json(
{
data: {
workspace: workspaceDecrypted,
personal: personalDecrypted,
conflicts,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Workspace env GET error`, error)
return NextResponse.json(
{ error: error.message || 'Failed to load environment' },
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const workspaceId = (await params).id
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workspace env update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { variables } = UpsertSchema.parse(body)
// Read existing encrypted ws vars
const existingRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const existingEncrypted: Record<string, string> = (existingRows[0]?.variables as any) || {}
// Encrypt incoming
const encryptedIncoming = await Promise.all(
Object.entries(variables).map(async ([key, value]) => {
const { encrypted } = await encryptSecret(value)
return [key, encrypted] as const
})
).then((entries) => Object.fromEntries(entries))
const merged = { ...existingEncrypted, ...encryptedIncoming }
// Upsert by unique workspace_id
await db
.insert(workspaceEnvironment)
.values({
id: crypto.randomUUID(),
workspaceId,
variables: merged,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: merged, updatedAt: new Date() },
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Workspace env PUT error`, error)
return NextResponse.json(
{ error: error.message || 'Failed to update environment' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const workspaceId = (await params).id
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { keys } = DeleteSchema.parse(body)
const wsRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const current: Record<string, string> = (wsRows[0]?.variables as any) || {}
let changed = false
for (const k of keys) {
if (k in current) {
delete current[k]
changed = true
}
}
if (!changed) {
return NextResponse.json({ success: true })
}
await db
.insert(workspaceEnvironment)
.values({
id: wsRows[0]?.id || crypto.randomUUID(),
workspaceId,
variables: current,
createdAt: wsRows[0]?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error)
return NextResponse.json(
{ error: error.message || 'Failed to remove environment keys' },
{ status: 500 }
)
}
}

View File

@@ -2,19 +2,16 @@ import crypto from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import { permissions, type permissionTypeEnum } from '@/db/schema'
const logger = createLogger('WorkspacesPermissionsAPI')
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
interface UpdatePermissionsRequest {
updates: Array<{
userId: string
permissions: PermissionType
permissions: PermissionType // Single permission type instead of object with booleans
}>
}
@@ -36,6 +33,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Verify the current user has access to this workspace
const userPermission = await db
.select()
.from(permissions)
@@ -59,7 +57,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
total: result.length,
})
} catch (error) {
logger.error('Error fetching workspace permissions:', error)
console.error('Error fetching workspace permissions:', error)
return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 })
}
}
@@ -83,6 +81,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Verify the current user has admin access to this workspace (either direct or through organization)
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
if (!hasAdminAccess) {
@@ -92,8 +91,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
}
// Parse and validate request body
const body: UpdatePermissionsRequest = await request.json()
// Prevent users from modifying their own admin permissions
const selfUpdate = body.updates.find((update) => update.userId === session.user.id)
if (selfUpdate && selfUpdate.permissions !== 'admin') {
return NextResponse.json(
@@ -102,8 +103,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
}
// Process updates in a transaction
await db.transaction(async (tx) => {
for (const update of body.updates) {
// Delete existing permissions for this user and workspace
await tx
.delete(permissions)
.where(
@@ -114,6 +117,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
)
// Insert the single new permission
await tx.insert(permissions).values({
id: crypto.randomUUID(),
userId: update.userId,
@@ -134,7 +138,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
total: updatedUsers.length,
})
} catch (error) {
logger.error('Error updating workspace permissions:', error)
console.error('Error updating workspace permissions:', error)
return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 })
}
}

View File

@@ -0,0 +1,241 @@
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSession } from '@/lib/auth'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { DELETE } from '@/app/api/workspaces/invitations/[id]/route'
import { db } from '@/db'
import { workspaceInvitation } from '@/db/schema'
vi.mock('@/lib/auth', () => ({
getSession: vi.fn(),
}))
vi.mock('@/lib/permissions/utils', () => ({
hasWorkspaceAdminAccess: vi.fn(),
}))
vi.mock('@/db', () => ({
db: {
select: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/db/schema', () => ({
workspaceInvitation: {
id: 'id',
workspaceId: 'workspaceId',
email: 'email',
inviterId: 'inviterId',
status: 'status',
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
}))
describe('DELETE /api/workspaces/invitations/[id]', () => {
const mockSession = {
user: {
id: 'user123',
email: 'user@example.com',
name: 'Test User',
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: null,
stripeCustomerId: null,
},
session: {
id: 'session123',
token: 'token123',
userId: 'user123',
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
activeOrganizationId: null,
},
}
const mockInvitation = {
id: 'invitation123',
workspaceId: 'workspace456',
email: 'invited@example.com',
inviterId: 'inviter789',
status: 'pending',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should return 401 when user is not authenticated', async () => {
vi.mocked(getSession).mockResolvedValue(null)
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'invitation123' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ error: 'Unauthorized' })
})
it('should return 404 when invitation does not exist', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
// Mock invitation not found
const mockQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn((callback: (rows: any[]) => any) => {
// Simulate empty rows array
return Promise.resolve(callback([]))
}),
}
vi.mocked(db.select).mockReturnValue(mockQuery as any)
const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'non-existent' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toEqual({ error: 'Invitation not found' })
})
it('should return 403 when user does not have admin access', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
// Mock invitation found
const mockQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn((callback: (rows: any[]) => any) => {
// Return the first invitation from the array
return Promise.resolve(callback([mockInvitation]))
}),
}
vi.mocked(db.select).mockReturnValue(mockQuery as any)
// Mock user does not have admin access
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false)
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'invitation123' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ error: 'Insufficient permissions' })
expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456')
})
it('should return 400 when trying to delete non-pending invitation', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
// Mock invitation with accepted status
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
const mockQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn((callback: (rows: any[]) => any) => {
// Return the first invitation from the array
return Promise.resolve(callback([acceptedInvitation]))
}),
}
vi.mocked(db.select).mockReturnValue(mockQuery as any)
// Mock user has admin access
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'invitation123' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ error: 'Can only delete pending invitations' })
})
it('should successfully delete pending invitation when user has admin access', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
// Mock invitation found
const mockQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn((callback: (rows: any[]) => any) => {
// Return the first invitation from the array
return Promise.resolve(callback([mockInvitation]))
}),
}
vi.mocked(db.select).mockReturnValue(mockQuery as any)
// Mock user has admin access
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
// Mock successful deletion
const mockDelete = {
where: vi.fn().mockResolvedValue({ rowCount: 1 }),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'invitation123' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(db.delete).toHaveBeenCalledWith(workspaceInvitation)
expect(mockDelete.where).toHaveBeenCalled()
})
it('should return 500 when database error occurs', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
// Mock database error
const mockQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
}
vi.mocked(db.select).mockReturnValue(mockQuery as any)
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'invitation123' })
const response = await DELETE(req, { params })
expect(response).toBeInstanceOf(NextResponse)
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toEqual({ error: 'Failed to delete invitation' })
})
})

View File

@@ -0,0 +1,55 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workspaceInvitation } from '@/db/schema'
// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Get the invitation to delete
const invitation = await db
.select({
id: workspaceInvitation.id,
workspaceId: workspaceInvitation.workspaceId,
email: workspaceInvitation.email,
inviterId: workspaceInvitation.inviterId,
status: workspaceInvitation.status,
})
.from(workspaceInvitation)
.where(eq(workspaceInvitation.id, id))
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
// Check if current user has admin access to the workspace
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
// Only allow deleting pending invitations
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
}
// Delete the invitation
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id))
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting workspace invitation:', error)
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
}
}

View File

@@ -1,405 +0,0 @@
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils'
/**
* Tests for workspace invitation by ID API route
* Tests GET (details + token acceptance), DELETE (cancellation)
*
* @vitest-environment node
*/
describe('Workspace Invitation [invitationId] API Route', () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
}
const mockWorkspace = {
id: 'workspace-456',
name: 'Test Workspace',
}
const mockInvitation = {
id: 'invitation-789',
workspaceId: 'workspace-456',
email: 'invited@example.com',
inviterId: 'inviter-321',
status: 'pending',
token: 'token-abc123',
permissions: 'read',
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
createdAt: new Date(),
updatedAt: new Date(),
}
let mockDbResults: any[] = []
let mockGetSession: any
let mockHasWorkspaceAdminAccess: any
let mockTransaction: any
beforeEach(async () => {
vi.resetModules()
vi.resetAllMocks()
mockDbResults = []
mockConsoleLogger()
mockAuth(mockUser)
vi.doMock('crypto', () => ({
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'),
}))
mockGetSession = vi.fn()
vi.doMock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
mockHasWorkspaceAdminAccess = vi.fn()
vi.doMock('@/lib/permissions/utils', () => ({
hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
}))
vi.doMock('@/lib/env', () => ({
env: {
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
},
}))
mockTransaction = vi.fn()
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockImplementation((callback: any) => {
const result = mockDbResults.shift() || []
return callback ? callback(result) : Promise.resolve(result)
}),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
transaction: mockTransaction,
}
vi.doMock('@/db', () => ({
db: mockDbChain,
}))
vi.doMock('@/db/schema', () => ({
workspaceInvitation: {
id: 'id',
workspaceId: 'workspaceId',
email: 'email',
inviterId: 'inviterId',
status: 'status',
token: 'token',
permissions: 'permissions',
expiresAt: 'expiresAt',
},
workspace: {
id: 'id',
name: 'name',
},
user: {
id: 'id',
email: 'email',
},
permissions: {
id: 'id',
entityType: 'entityType',
entityId: 'entityId',
userId: 'userId',
permissionType: 'permissionType',
},
}))
vi.doMock('drizzle-orm', () => ({
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
and: vi.fn((...args) => ({ type: 'and', args })),
}))
})
describe('GET /api/workspaces/invitations/[invitationId]', () => {
it('should return invitation details when called without token', async () => {
const { GET } = await import('./route')
mockGetSession.mockResolvedValue({ user: mockUser })
mockDbResults.push([mockInvitation])
mockDbResults.push([mockWorkspace])
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toMatchObject({
id: 'invitation-789',
email: 'invited@example.com',
status: 'pending',
workspaceName: 'Test Workspace',
})
})
it('should redirect to login when unauthenticated with token', async () => {
const { GET } = await import('./route')
mockGetSession.mockResolvedValue(null)
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/token-abc123?token=token-abc123'
)
})
it('should accept invitation when called with valid token', async () => {
const { GET } = await import('./route')
mockGetSession.mockResolvedValue({
user: { ...mockUser, email: 'invited@example.com' },
})
mockDbResults.push([mockInvitation])
mockDbResults.push([mockWorkspace])
mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }])
mockDbResults.push([])
mockTransaction.mockImplementation(async (callback: any) => {
await callback({
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
})
})
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
})
it('should redirect to error page when invitation expired', async () => {
const { GET } = await import('./route')
mockGetSession.mockResolvedValue({
user: { ...mockUser, email: 'invited@example.com' },
})
const expiredInvitation = {
...mockInvitation,
expiresAt: new Date(Date.now() - 86400000), // 1 day ago
}
mockDbResults.push([expiredInvitation])
mockDbResults.push([mockWorkspace])
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=expired'
)
})
it('should redirect to error page when email mismatch', async () => {
const { GET } = await import('./route')
mockGetSession.mockResolvedValue({
user: { ...mockUser, email: 'wrong@example.com' },
})
mockDbResults.push([mockInvitation])
mockDbResults.push([mockWorkspace])
mockDbResults.push([{ ...mockUser, email: 'wrong@example.com' }])
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
)
})
})
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {
it('should return 401 when user is not authenticated', async () => {
const { DELETE } = await import('./route')
mockGetSession.mockResolvedValue(null)
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/invitation-789',
{
method: 'DELETE',
}
)
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ error: 'Unauthorized' })
})
it('should return 404 when invitation does not exist', async () => {
const { DELETE } = await import('./route')
mockGetSession.mockResolvedValue({ user: mockUser })
mockDbResults.push([])
const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
method: 'DELETE',
})
const params = Promise.resolve({ invitationId: 'non-existent' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toEqual({ error: 'Invitation not found' })
})
it('should return 403 when user lacks admin access', async () => {
const { DELETE } = await import('./route')
mockGetSession.mockResolvedValue({ user: mockUser })
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
mockDbResults.push([mockInvitation])
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/invitation-789',
{
method: 'DELETE',
}
)
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ error: 'Insufficient permissions' })
expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-123', 'workspace-456')
})
it('should return 400 when trying to delete non-pending invitation', async () => {
const { DELETE } = await import('./route')
mockGetSession.mockResolvedValue({ user: mockUser })
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
mockDbResults.push([acceptedInvitation])
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/invitation-789',
{
method: 'DELETE',
}
)
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ error: 'Can only delete pending invitations' })
})
it('should successfully delete pending invitation when user has admin access', async () => {
const { DELETE } = await import('./route')
mockGetSession.mockResolvedValue({ user: mockUser })
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
mockDbResults.push([mockInvitation])
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/invitation-789',
{
method: 'DELETE',
}
)
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
})
it('should return 500 when database error occurs', async () => {
vi.resetModules()
const mockErrorDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
}
vi.doMock('@/db', () => ({ db: mockErrorDb }))
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({ user: mockUser }),
}))
vi.doMock('@/lib/permissions/utils', () => ({
hasWorkspaceAdminAccess: vi.fn(),
}))
vi.doMock('@/db/schema', () => ({
workspaceInvitation: { id: 'id' },
}))
vi.doMock('drizzle-orm', () => ({
eq: vi.fn(),
}))
const { DELETE } = await import('./route')
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/invitation-789',
{
method: 'DELETE',
}
)
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await DELETE(request, { params })
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toEqual({ error: 'Failed to delete invitation' })
})
})
})

View File

@@ -1,236 +0,0 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import {
permissions,
user,
type WorkspaceInvitationStatus,
workspace,
workspaceInvitation,
} from '@/db/schema'
// GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
const session = await getSession()
const token = req.nextUrl.searchParams.get('token')
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
if (!session?.user?.id) {
// For token-based acceptance flows, redirect to login
if (isAcceptFlow) {
return NextResponse.redirect(
new URL(
`/invite/${invitationId}?token=${token}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const whereClause = token
? eq(workspaceInvitation.token, token)
: eq(workspaceInvitation.id, invitationId)
const invitation = await db
.select()
.from(workspaceInvitation)
.where(whereClause)
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
}
if (new Date() > new Date(invitation.expiresAt)) {
if (isAcceptFlow) {
return NextResponse.redirect(
new URL(
`/invite/${invitation.id}?error=expired`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
}
const workspaceDetails = await db
.select()
.from(workspace)
.where(eq(workspace.id, invitation.workspaceId))
.then((rows) => rows[0])
if (!workspaceDetails) {
if (isAcceptFlow) {
return NextResponse.redirect(
new URL(
`/invite/${invitation.id}?error=workspace-not-found`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (isAcceptFlow) {
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
return NextResponse.redirect(
new URL(
`/invite/${invitation.id}?error=already-processed`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const userEmail = session.user.email.toLowerCase()
const invitationEmail = invitation.email.toLowerCase()
const userData = await db
.select()
.from(user)
.where(eq(user.id, session.user.id))
.then((rows) => rows[0])
if (!userData) {
return NextResponse.redirect(
new URL(
`/invite/${invitation.id}?error=user-not-found`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const isValidMatch = userEmail === invitationEmail
if (!isValidMatch) {
return NextResponse.redirect(
new URL(
`/invite/${invitation.id}?error=email-mismatch`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const existingPermission = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.entityId, invitation.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, session.user.id)
)
)
.then((rows) => rows[0])
if (existingPermission) {
await db
.update(workspaceInvitation)
.set({
status: 'accepted' as WorkspaceInvitationStatus,
updatedAt: new Date(),
})
.where(eq(workspaceInvitation.id, invitation.id))
return NextResponse.redirect(
new URL(
`/workspace/${invitation.workspaceId}/w`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
await db.transaction(async (tx) => {
await tx.insert(permissions).values({
id: randomUUID(),
entityType: 'workspace' as const,
entityId: invitation.workspaceId,
userId: session.user.id,
permissionType: invitation.permissions || 'read',
createdAt: new Date(),
updatedAt: new Date(),
})
await tx
.update(workspaceInvitation)
.set({
status: 'accepted' as WorkspaceInvitationStatus,
updatedAt: new Date(),
})
.where(eq(workspaceInvitation.id, invitation.id))
})
return NextResponse.redirect(
new URL(
`/workspace/${invitation.workspaceId}/w`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
return NextResponse.json({
...invitation,
workspaceName: workspaceDetails.name,
})
} catch (error) {
console.error('Error fetching workspace invitation:', error)
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
}
}
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const invitation = await db
.select({
id: workspaceInvitation.id,
workspaceId: workspaceInvitation.workspaceId,
email: workspaceInvitation.email,
inviterId: workspaceInvitation.inviterId,
status: workspaceInvitation.status,
})
.from(workspaceInvitation)
.where(eq(workspaceInvitation.id, invitationId))
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
}
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting workspace invitation:', error)
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,191 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { db } from '@/db'
import { permissions, user, workspace, workspaceInvitation } from '@/db/schema'
// Accept an invitation via token
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=missing-token',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
const session = await getSession()
if (!session?.user?.id) {
// No need to encode API URL as callback, just redirect to invite page
// The middleware will handle proper login flow and return to invite page
return NextResponse.redirect(
new URL(`/invite/${token}?token=${token}`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
)
}
try {
// Find the invitation by token
const invitation = await db
.select()
.from(workspaceInvitation)
.where(eq(workspaceInvitation.token, token))
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=invalid-token',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if invitation has expired
if (new Date() > new Date(invitation.expiresAt)) {
return NextResponse.redirect(
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
)
}
// Check if invitation is already accepted
if (invitation.status !== 'pending') {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=already-processed',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Get the user's email from the session
const userEmail = session.user.email.toLowerCase()
const invitationEmail = invitation.email.toLowerCase()
// Get user data to check email verification status and for error messages
const userData = await db
.select()
.from(user)
.where(eq(user.id, session.user.id))
.then((rows) => rows[0])
if (!userData) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=user-not-found',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user's email is verified
if (!userData.emailVerified) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData.email}) before accepting invitations.`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if the logged-in user's email matches the invitation
const isValidMatch = userEmail === invitationEmail
if (!isValidMatch) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData.email}`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Get the workspace details
const workspaceDetails = await db
.select()
.from(workspace)
.where(eq(workspace.id, invitation.workspaceId))
.then((rows) => rows[0])
if (!workspaceDetails) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=workspace-not-found',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user already has permissions for this workspace
const existingPermission = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.entityId, invitation.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, session.user.id)
)
)
.then((rows) => rows[0])
if (existingPermission) {
// User already has permissions, just mark the invitation as accepted and redirect
await db
.update(workspaceInvitation)
.set({
status: 'accepted',
updatedAt: new Date(),
})
.where(eq(workspaceInvitation.id, invitation.id))
return NextResponse.redirect(
new URL(
`/workspace/${invitation.workspaceId}/w`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Add user permissions and mark invitation as accepted in a transaction
await db.transaction(async (tx) => {
// Create permissions for the user
await tx.insert(permissions).values({
id: randomUUID(),
entityType: 'workspace' as const,
entityId: invitation.workspaceId,
userId: session.user.id,
permissionType: invitation.permissions || 'read',
createdAt: new Date(),
updatedAt: new Date(),
})
// Mark invitation as accepted
await tx
.update(workspaceInvitation)
.set({
status: 'accepted',
updatedAt: new Date(),
})
.where(eq(workspaceInvitation.id, invitation.id))
})
// Redirect to the workspace
return NextResponse.redirect(
new URL(`/workspace/${invitation.workspaceId}/w`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
)
} catch (error) {
console.error('Error accepting invitation:', error)
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=server-error',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
}

View File

@@ -0,0 +1,58 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { workspace, workspaceInvitation } from '@/db/schema'
// Get invitation details by token
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
}
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Find the invitation by token
const invitation = await db
.select()
.from(workspaceInvitation)
.where(eq(workspaceInvitation.token, token))
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
}
// Check if invitation has expired
if (new Date() > new Date(invitation.expiresAt)) {
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
}
// Get workspace details
const workspaceDetails = await db
.select()
.from(workspace)
.where(eq(workspace.id, invitation.workspaceId))
.then((rows) => rows[0])
if (!workspaceDetails) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Return the invitation with workspace name
return NextResponse.json({
...invitation,
workspaceName: workspaceDetails.name,
})
} catch (error) {
console.error('Error fetching workspace invitation:', error)
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
}
}

View File

@@ -13,7 +13,6 @@ import {
permissions,
type permissionTypeEnum,
user,
type WorkspaceInvitationStatus,
workspace,
workspaceInvitation,
} from '@/db/schema'
@@ -163,7 +162,7 @@ export async function POST(req: NextRequest) {
and(
eq(workspaceInvitation.workspaceId, workspaceId),
eq(workspaceInvitation.email, email),
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
eq(workspaceInvitation.status, 'pending')
)
)
.then((rows) => rows[0])
@@ -190,7 +189,7 @@ export async function POST(req: NextRequest) {
email,
inviterId: session.user.id,
role,
status: 'pending' as WorkspaceInvitationStatus,
status: 'pending',
token,
permissions: permission,
expiresAt,
@@ -206,7 +205,6 @@ export async function POST(req: NextRequest) {
to: email,
inviterName: session.user.name || session.user.email || 'A user',
workspaceName: workspaceDetails.name,
invitationId: invitationData.id,
token: token,
})
@@ -222,19 +220,17 @@ async function sendInvitationEmail({
to,
inviterName,
workspaceName,
invitationId,
token,
}: {
to: string
inviterName: string
workspaceName: string
invitationId: string
token: string
}) {
try {
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
// Use invitation ID in path, token in query parameter for security
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}`
// Always use the client-side invite route with token parameter
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
const emailHtml = await render(
WorkspaceInvitationEmail({

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlAutoLayoutAPI')
@@ -26,6 +28,7 @@ const AutoLayoutRequestSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()).optional().default({}),
parallels: z.record(z.any()).optional().default({}),
whiles: z.record(z.any()).optional().default({}),
}),
options: z
.object({
@@ -36,6 +39,7 @@ const AutoLayoutRequestSchema = z.object({
horizontal: z.number().optional(),
vertical: z.number().optional(),
layer: z.number().optional(),
while: z.number().optional(),
})
.optional(),
alignment: z.enum(['start', 'center', 'end']).optional(),
@@ -45,6 +49,12 @@ const AutoLayoutRequestSchema = z.object({
y: z.number().optional(),
})
.optional(),
while: z
.object({
x: z.number().optional(),
y: z.number().optional(),
})
.optional(),
})
.optional(),
})
@@ -133,8 +143,10 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
convertLoopBlockToLoop: convertLoopBlockToLoop.toString(),
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})
@@ -192,6 +204,7 @@ export async function POST(request: NextRequest) {
edges: workflowState.edges || [],
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
whiles: workflowState.whiles || {},
},
errors: result.errors,
}

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlDiffCreateAPI')
@@ -130,8 +132,10 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
convertLoopBlockToLoop: convertLoopBlockToLoop.toString(),
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options,
}),
@@ -168,7 +172,7 @@ export async function POST(request: NextRequest) {
dataKeys: block.data ? Object.keys(block.data) : [],
})
}
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
type: block.type,
hasData: !!block.data,
@@ -180,8 +184,10 @@ export async function POST(request: NextRequest) {
// Log existing loops/parallels from sim-agent
const loops = result.diff?.proposedState?.loops || result.loops || {}
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
const whiles = result.diff?.proposedState?.whiles || result.whiles || {}
logger.info(`[${requestId}] Sim agent loops:`, loops)
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
logger.info(`[${requestId}] Sim agent whiles:`, whiles)
}
// Log diff analysis specifically
@@ -207,7 +213,7 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
@@ -251,17 +257,23 @@ export async function POST(request: NextRequest) {
// Now regenerate loops and parallels with the fixed relationships
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
const whiles = generateWhileBlocks(result.diff.proposedState.blocks)
result.diff.proposedState.loops = loops
result.diff.proposedState.parallels = parallels
result.diff.proposedState.whiles = whiles
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
loops: Object.keys(loops).map((id) => ({
id,
nodes: loops[id].nodes,
})),
whiles: Object.keys(whiles).map((id) => ({
id,
nodes: whiles[id].nodes,
})),
})
}
@@ -309,7 +321,7 @@ export async function POST(request: NextRequest) {
// Generate loops and parallels for the blocks with fixed relationships
const loops = generateLoopBlocks(result.blocks)
const parallels = generateParallelBlocks(result.blocks)
const whiles = generateWhileBlocks(result.blocks)
const transformedResult = {
success: result.success,
diff: {
@@ -318,6 +330,7 @@ export async function POST(request: NextRequest) {
edges: result.edges || [],
loops: loops,
parallels: parallels,
whiles: whiles,
},
diffAnalysis: diffAnalysis,
metadata: result.metadata || {

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlDiffMergeAPI')
@@ -27,6 +29,7 @@ const MergeDiffRequestSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
whiles: z.record(z.any()).optional(),
}),
diffAnalysis: z.any().optional(),
metadata: z.object({
@@ -103,6 +106,8 @@ export async function POST(request: NextRequest) {
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
},
options,
}),
@@ -139,7 +144,7 @@ export async function POST(request: NextRequest) {
dataKeys: block.data ? Object.keys(block.data) : [],
})
}
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
type: block.type,
hasData: !!block.data,
@@ -151,8 +156,10 @@ export async function POST(request: NextRequest) {
// Log existing loops/parallels from sim-agent
const loops = result.diff?.proposedState?.loops || result.loops || {}
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
const whiles = result.diff?.proposedState?.whiles || result.whiles || {}
logger.info(`[${requestId}] Sim agent loops:`, loops)
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
logger.info(`[${requestId}] Sim agent whiles:`, whiles)
}
// Post-process the result to ensure loops and parallels are properly generated
@@ -165,13 +172,16 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
containerBlocks.forEach((container: any) => {
const childEdges = edges.filter(
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
(edge: any) =>
edge.source === container.id &&
(edge.sourceHandle === 'loop-start-source' ||
edge.sourceHandle === 'while-start-source')
)
childEdges.forEach((edge: any) => {
@@ -198,17 +208,23 @@ export async function POST(request: NextRequest) {
// Now regenerate loops and parallels with the fixed relationships
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
const whiles = generateWhileBlocks(result.diff.proposedState.blocks)
result.diff.proposedState.loops = loops
result.diff.proposedState.parallels = parallels
result.diff.proposedState.whiles = whiles
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
loops: Object.keys(loops).map((id) => ({
id,
nodes: loops[id].nodes,
})),
whiles: Object.keys(whiles).map((id) => ({
id,
nodes: whiles[id].nodes,
})),
})
}
@@ -223,13 +239,16 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
containerBlocks.forEach((container: any) => {
const childEdges = edges.filter(
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
(edge: any) =>
edge.source === container.id &&
(edge.sourceHandle === 'loop-start-source' ||
edge.sourceHandle === 'while-start-source')
)
childEdges.forEach((edge: any) => {
@@ -256,7 +275,7 @@ export async function POST(request: NextRequest) {
// Generate loops and parallels for the blocks with fixed relationships
const loops = generateLoopBlocks(result.blocks)
const parallels = generateParallelBlocks(result.blocks)
const whiles = generateWhileBlocks(result.blocks)
const transformedResult = {
success: result.success,
diff: {
@@ -265,6 +284,7 @@ export async function POST(request: NextRequest) {
edges: result.edges || existingDiff.proposedState.edges || [],
loops: loops,
parallels: parallels,
whiles: whiles,
},
diffAnalysis: diffAnalysis,
metadata: result.metadata || {

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlGenerateAPI')
@@ -60,6 +64,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlParseAPI')
@@ -57,6 +61,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlToWorkflowAPI')
@@ -65,6 +69,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options,
}),

View File

@@ -161,12 +161,6 @@
color: hsl(var(--foreground));
}
/* Tooltip overrides - keep tooltips black with white text for consistency */
.chat-light-wrapper [data-radix-tooltip-content] {
background-color: hsl(0 0% 3.9%) !important;
color: hsl(0 0% 98%) !important;
}
/* Force color scheme */
.chat-light-wrapper {
color-scheme: light !important;

View File

@@ -15,8 +15,8 @@ import {
EmailAuth,
PasswordAuth,
VoiceInterface,
} from '@/app/chat/components'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
} from '@/app/chat/[subdomain]/components'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/[subdomain]/hooks'
const logger = createLogger('ChatClient')

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { Send, Square } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { VoiceInput } from '@/app/chat/components/input/voice-input'
import { VoiceInput } from '@/app/chat/[subdomain]/components/input/voice-input'
const PLACEHOLDER_MOBILE = 'Enter a message'
const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak'
@@ -118,7 +118,7 @@ export const ChatInput: React.FC<{
<VoiceInput onVoiceStart={handleVoiceStart} disabled={isStreaming} large={true} />
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<TooltipContent side='top' className='border border-gray-200 bg-white text-gray-900'>
<p>Start voice conversation</p>
</TooltipContent>
</Tooltip>

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