mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784992f347 | ||
|
|
5218dd41b9 | ||
|
|
07e70409c7 | ||
|
|
07ba17422b | ||
|
|
d45324bb83 | ||
|
|
ced64129da | ||
|
|
1e14743391 | ||
|
|
a0bb754c8c | ||
|
|
851031239d | ||
|
|
3811b509ef | ||
|
|
abb835d22d | ||
|
|
f2a046ff24 | ||
|
|
bd6d4a91a3 | ||
|
|
21beca8fd5 | ||
|
|
0a86eda853 | ||
|
|
60a061e38a | ||
|
|
ab71fcfc49 | ||
|
|
864622c1dc | ||
|
|
8668622d66 | ||
|
|
53dd277cfe | ||
|
|
0e8e8c7a47 | ||
|
|
47da5eb6e8 | ||
|
|
37dcde2afc | ||
|
|
e31627c7c2 | ||
|
|
57c98d86ba | ||
|
|
0f7dfe084a | ||
|
|
afc1632830 | ||
|
|
56eee2c2d2 | ||
|
|
fc558a8eef | ||
|
|
c68cadfb84 | ||
|
|
95d93a2532 | ||
|
|
59b2023124 | ||
|
|
a672f17136 | ||
|
|
1de59668e4 | ||
|
|
26243b99e8 | ||
|
|
fce1423d05 | ||
|
|
3656d3d7ad | ||
|
|
581929bc01 | ||
|
|
11d8188415 | ||
|
|
36c98d18e9 | ||
|
|
0cf87e650d | ||
|
|
baef8d77f9 | ||
|
|
b74ab46820 | ||
|
|
533b4c53e0 | ||
|
|
c2d668c3eb | ||
|
|
1a5d5ddffa | ||
|
|
9de0d91f9a | ||
|
|
3db73ff721 | ||
|
|
9ffb48ee02 | ||
|
|
1f2a317ac2 | ||
|
|
a618d289d8 | ||
|
|
461d7b2342 | ||
|
|
4273161c0f | ||
|
|
54d42b33eb | ||
|
|
2c2c32c64b | ||
|
|
65e861822c | ||
|
|
12135d2aa8 | ||
|
|
f75c807580 | ||
|
|
9ea7ea79e9 | ||
|
|
5bbb349d8a | ||
|
|
ea09fcecb7 | ||
|
|
9ccb7600f9 | ||
|
|
ee17cf461a | ||
|
|
43cb124d97 | ||
|
|
76889fde26 | ||
|
|
7780d9b32b | ||
|
|
4a703a02cb | ||
|
|
a969d09782 | ||
|
|
0bc778130f | ||
|
|
df3d532495 | ||
|
|
f4f8fc051e | ||
|
|
76fac13f3d | ||
|
|
a3838302e0 | ||
|
|
4310dd6c15 | ||
|
|
813a0fb741 | ||
|
|
7e23e942d7 | ||
|
|
7fcbafab97 | ||
|
|
056dc2879c | ||
|
|
1aec32b7e2 | ||
|
|
316c9704af | ||
|
|
4e3a3bd1b1 | ||
|
|
36773e8cdb | ||
|
|
7ac89e35a1 | ||
|
|
faa094195a | ||
|
|
69319d21cd | ||
|
|
8362fd7a83 | ||
|
|
39ad793a9a | ||
|
|
921c755711 | ||
|
|
41ec75fcad | ||
|
|
f2502f5e48 | ||
|
|
f3c4f7e20a | ||
|
|
f578f43c9a | ||
|
|
5c73038023 | ||
|
|
92132024ca | ||
|
|
ed11456de3 | ||
|
|
8739a3d378 | ||
|
|
ca015deea9 |
@@ -9,8 +9,8 @@ services:
|
||||
command: sleep infinity
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:${POSTGRES_PORT:-5432}/simstudio
|
||||
- POSTGRES_URL=postgresql://postgres:postgres@db:${POSTGRES_PORT:-5432}/simstudio
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
- POSTGRES_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
- BETTER_AUTH_URL=http://localhost:3000
|
||||
- NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
- BUN_INSTALL_CACHE_DIR=/home/bun/.bun/cache
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
command: sleep infinity
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:${POSTGRES_PORT:-5432}/simstudio
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
- BETTER_AUTH_URL=http://localhost:3000
|
||||
- NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
depends_on:
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: docker/db.Dockerfile
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:${POSTGRES_PORT:-5432}/simstudio
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -2,8 +2,7 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -56,7 +55,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -70,10 +69,7 @@ jobs:
|
||||
images: ${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
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=raw,value=staging-${{ github.sha }}-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }}
|
||||
type=sha,format=long,suffix=-${{ matrix.arch }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -82,7 +78,7 @@ jobs:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=build-v3
|
||||
@@ -93,7 +89,7 @@ jobs:
|
||||
create-manifests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -119,10 +115,6 @@ 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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
node-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
env:
|
||||
|
||||
@@ -159,7 +159,7 @@ bun run dev:sockets
|
||||
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
|
||||
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
- Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
After a router makes a decision, you can access its outputs:
|
||||
|
||||
- **`<router.content>`**: Summary of the routing decision made
|
||||
- **`<router.prompt>`**: Summary of the routing prompt used
|
||||
- **`<router.selected_path>`**: Details of the chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics from the LLM
|
||||
- **`<router.model>`**: The model used for decision-making
|
||||
@@ -182,7 +182,7 @@ Confidence Threshold: 0.7 // Minimum confidence for routing
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>router.content</strong>: Summary of routing decision
|
||||
<strong>router.prompt</strong>: Summary of routing prompt used
|
||||
</li>
|
||||
<li>
|
||||
<strong>router.selected_path</strong>: Details of chosen destination
|
||||
|
||||
@@ -91,4 +91,31 @@ Copilot is your in-editor assistant that helps you build, understand, and improv
|
||||
>
|
||||
<div className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
</Cards>
|
||||
|
||||
## Billing and Cost Calculation
|
||||
|
||||
### How Costs Are Calculated
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM:
|
||||
|
||||
- **Input tokens**: billed at the provider's base rate (**at-cost**)
|
||||
- **Output tokens**: billed at **1.5×** the provider's base output rate
|
||||
|
||||
```javascript
|
||||
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
|
||||
```
|
||||
|
||||
| Component | Rate Applied |
|
||||
|----------|----------------------|
|
||||
| Input | inputPrice |
|
||||
| Output | outputPrice × 1.5 |
|
||||
|
||||
<Callout type="warning">
|
||||
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See <a href="/execution/advanced#cost-calculation">Logging and Cost Calculation</a> for background and examples.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `projectId` | string | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. |
|
||||
| `projectId` | string | No | Jira project ID \(optional; not required to retrieve a single issue\). |
|
||||
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"microsoft_planner",
|
||||
"microsoft_teams",
|
||||
"mistral_parse",
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"notion",
|
||||
"onedrive",
|
||||
|
||||
264
apps/docs/content/docs/tools/mongodb.mdx
Normal file
264
apps/docs/content/docs/tools/mongodb.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
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`
|
||||
@@ -304,6 +304,15 @@ 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: '' })
|
||||
@@ -321,7 +330,23 @@ export default function LoginPage({
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to request password reset')
|
||||
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)
|
||||
}
|
||||
|
||||
setResetStatus({
|
||||
@@ -497,7 +522,8 @@ 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.
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
@@ -512,14 +538,20 @@ export default function LoginPage({
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
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'
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{resetStatus.type && (
|
||||
<div
|
||||
className={`text-sm ${resetStatus.type === 'success' ? 'text-[#4CAF50]' : 'text-red-500'}`}
|
||||
>
|
||||
{resetStatus.message}
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -166,8 +166,9 @@ describe('SignupPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should prevent submission with invalid name validation', async () => {
|
||||
it('should automatically trim spaces from name input', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
@@ -176,22 +177,20 @@ 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(
|
||||
screen.getByText(
|
||||
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'John Doe',
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,10 +49,6 @@ 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[] => {
|
||||
@@ -175,10 +171,6 @@ function SignupFormContent({
|
||||
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -193,11 +185,10 @@ function SignupFormContent({
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
setName(newName)
|
||||
const rawValue = e.target.value
|
||||
setName(rawValue)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateName(newName)
|
||||
const errors = validateName(rawValue)
|
||||
setNameErrors(errors)
|
||||
setShowNameValidationError(false)
|
||||
}
|
||||
@@ -224,23 +215,21 @@ function SignupFormContent({
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValue = formData.get('email') as string
|
||||
const passwordValue = formData.get('password') as string
|
||||
const name = formData.get('name') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
// Validate name on submit
|
||||
const nameValidationErrors = validateName(name)
|
||||
const trimmedName = nameValue.trim()
|
||||
|
||||
const nameValidationErrors = validateName(trimmedName)
|
||||
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 {
|
||||
@@ -249,7 +238,6 @@ 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)
|
||||
@@ -266,8 +254,6 @@ 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)
|
||||
@@ -337,7 +323,6 @@ 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
|
||||
|
||||
@@ -215,20 +215,28 @@ 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'
|
||||
|
||||
|
||||
@@ -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-billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, userStats } from '@/db/schema'
|
||||
|
||||
@@ -16,7 +16,8 @@ const UpdateCostSchema = z.object({
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
multiplier: z.number().min(0),
|
||||
inputMultiplier: z.number().min(0),
|
||||
outputMultiplier: z.number().min(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -75,14 +76,15 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model, multiplier } = validation.data
|
||||
const { userId, input, output, model, inputMultiplier, outputMultiplier } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
multiplier,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
@@ -95,7 +97,8 @@ export async function POST(req: NextRequest) {
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
multiplier
|
||||
inputMultiplier,
|
||||
outputMultiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
@@ -104,7 +107,8 @@ export async function POST(req: NextRequest) {
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
multiplier,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -12,7 +13,7 @@ import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { db } from '@/db'
|
||||
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
|
||||
import { chat, userStats, workflow } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -453,18 +454,21 @@ export async function executeWorkflowForChat(
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get user environment variables for this workflow
|
||||
// Get user environment variables with workspace precedence
|
||||
let envVars: Record<string, string> = {}
|
||||
try {
|
||||
const envResult = await db
|
||||
.select()
|
||||
.from(envTable)
|
||||
.where(eq(envTable.userId, deployment.userId))
|
||||
const wfWorkspaceRow = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (envResult.length > 0 && envResult[0].variables) {
|
||||
envVars = envResult[0].variables as Record<string, string>
|
||||
}
|
||||
const workspaceId = wfWorkspaceRow[0]?.workspaceId || undefined
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
deployment.userId,
|
||||
workspaceId
|
||||
)
|
||||
envVars = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,9 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -286,7 +288,9 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -337,7 +341,9 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -425,7 +431,9 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'ask',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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,13 +50,25 @@ const ChatMessageSchema = z.object({
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.enum(['past_chat', 'workflow', 'blocks', 'logs', 'knowledge', 'templates']),
|
||||
kind: z.enum([
|
||||
'past_chat',
|
||||
'workflow',
|
||||
'current_workflow',
|
||||
'blocks',
|
||||
'logs',
|
||||
'workflow_block',
|
||||
'knowledge',
|
||||
'templates',
|
||||
'docs',
|
||||
]),
|
||||
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(),
|
||||
@@ -96,6 +108,8 @@ 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),
|
||||
@@ -105,6 +119,7 @@ export async function POST(req: NextRequest) {
|
||||
kind: c?.kind,
|
||||
chatId: c?.chatId,
|
||||
workflowId: c?.workflowId,
|
||||
executionId: (c as any)?.executionId,
|
||||
label: c?.label,
|
||||
}))
|
||||
: undefined,
|
||||
@@ -115,13 +130,18 @@ 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)
|
||||
const processed = await processContextsServer(contexts as any, authenticatedUserId, message)
|
||||
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)
|
||||
}
|
||||
@@ -351,12 +371,14 @@ 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 } : {}),
|
||||
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(agentContexts.length > 0 && { context: agentContexts }),
|
||||
...(actualChatId ? { chatId: actualChatId } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -396,7 +418,7 @@ export async function POST(req: NextRequest) {
|
||||
if (stream && simAgentResponse.body) {
|
||||
// Create user message to save
|
||||
const userMessage = {
|
||||
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -474,16 +496,6 @@ 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
|
||||
@@ -583,6 +595,47 @@ 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
|
||||
@@ -615,10 +668,37 @@ export async function POST(req: NextRequest) {
|
||||
logger.debug(`[${tracker.requestId}] Processing remaining buffer: "${buffer}"`)
|
||||
if (buffer.startsWith('data: ')) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6))
|
||||
const jsonStr = buffer.slice(6)
|
||||
const event = JSON.parse(jsonStr)
|
||||
if (event.type === 'content' && event.data) {
|
||||
assistantContent += event.data
|
||||
}
|
||||
// Forward remaining event, applying same error rewrite behavior
|
||||
if (event?.type === 'error') {
|
||||
const displayMessage: string =
|
||||
(event?.data && (event.data.displayMessage as string)) ||
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
const formatted = `_${displayMessage}_`
|
||||
assistantContent += formatted
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to parse final buffer: "${buffer}"`)
|
||||
}
|
||||
@@ -734,7 +814,7 @@ export async function POST(req: NextRequest) {
|
||||
// Save messages if we have a chat
|
||||
if (currentChat && responseData.content) {
|
||||
const userMessage = {
|
||||
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
68
apps/sim/app/api/copilot/stats/route.ts
Normal file
68
apps/sim/app/api/copilot/stats/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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({
|
||||
messageId: z.string(),
|
||||
diffCreated: z.boolean(),
|
||||
diffAccepted: z.boolean(),
|
||||
})
|
||||
|
||||
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 { messageId, diffCreated, diffAccepted } = parsed.data as any
|
||||
|
||||
// Build outgoing payload for Sim Agent with only required fields
|
||||
const payload: Record<string, any> = {
|
||||
messageId,
|
||||
diffCreated,
|
||||
diffAccepted,
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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()),
|
||||
})
|
||||
@@ -30,17 +29,13 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { variables } = EnvVarSchema.parse(body)
|
||||
|
||||
// Encrypt all variables
|
||||
const encryptedVariables = await Object.entries(variables).reduce(
|
||||
async (accPromise, [key, value]) => {
|
||||
const acc = await accPromise
|
||||
const encryptedVariables = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return { ...acc, [key]: encrypted }
|
||||
},
|
||||
Promise.resolve({})
|
||||
)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Replace all environment variables for user
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
@@ -80,7 +75,6 @@ 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`)
|
||||
@@ -99,18 +93,15 @@ 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: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,9 @@ 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,
|
||||
@@ -91,12 +89,10 @@ 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,
|
||||
@@ -105,7 +101,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, // We only return text content
|
||||
binary: false,
|
||||
},
|
||||
filePath: result.filePath,
|
||||
})
|
||||
@@ -120,15 +116,12 @@ 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,
|
||||
@@ -142,8 +135,6 @@ 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)
|
||||
@@ -164,7 +155,6 @@ 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,
|
||||
@@ -173,7 +163,6 @@ async function parseFileSingle(filePath: string, fileType?: string): Promise<Par
|
||||
}
|
||||
}
|
||||
|
||||
// Validate path for security before any processing
|
||||
const pathValidation = validateFilePath(filePath)
|
||||
if (!pathValidation.isValid) {
|
||||
return {
|
||||
@@ -183,49 +172,40 @@ 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
|
||||
* Validate file path for security - prevents null byte injection and path traversal attacks
|
||||
*/
|
||||
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' }
|
||||
}
|
||||
@@ -260,12 +240,10 @@ 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)
|
||||
}
|
||||
@@ -276,7 +254,6 @@ 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)
|
||||
@@ -289,35 +266,29 @@ async function handleExternalUrl(url: string, fileType?: string): Promise<ParseR
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file stored in cloud storage (S3 or Azure Blob)
|
||||
* Handle file stored in cloud storage
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -325,22 +296,19 @@ 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)
|
||||
|
||||
// Check if this is a download/access error that should trigger a 500 response
|
||||
// For download/access errors, throw to trigger 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
|
||||
// For other errors (parsing, processing), return success:false and an error message
|
||||
return {
|
||||
success: false,
|
||||
error: `Error accessing file from cloud storage: ${errorMessage}`,
|
||||
@@ -354,28 +322,23 @@ 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 {
|
||||
@@ -386,7 +349,7 @@ async function handleLocalFile(filePath: string, fileType?: string): Promise<Par
|
||||
fileType: fileType || getMimeType(extension),
|
||||
size: stats.size,
|
||||
hash,
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -425,15 +388,14 @@ async function handlePdfBuffer(
|
||||
fileType: fileType || 'application/pdf',
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse PDF in memory:', error)
|
||||
|
||||
// Create fallback message for PDF parsing failure
|
||||
const content = createPdfFailureMessage(
|
||||
0, // We can't determine page count without parsing
|
||||
0,
|
||||
fileBuffer.length,
|
||||
originalPath || filename,
|
||||
(error as Error).message
|
||||
@@ -447,7 +409,7 @@ async function handlePdfBuffer(
|
||||
fileType: fileType || 'application/pdf',
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -465,7 +427,6 @@ 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')
|
||||
|
||||
@@ -477,7 +438,7 @@ async function handleCsvBuffer(
|
||||
fileType: fileType || 'text/csv',
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -490,7 +451,7 @@ async function handleCsvBuffer(
|
||||
fileType: 'text/csv',
|
||||
size: 0,
|
||||
hash: '',
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -509,7 +470,6 @@ 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')
|
||||
|
||||
@@ -524,7 +484,7 @@ async function handleGenericTextBuffer(
|
||||
fileType: fileType || getMimeType(extension),
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -532,7 +492,6 @@ 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 {
|
||||
@@ -543,7 +502,7 @@ async function handleGenericTextBuffer(
|
||||
fileType: fileType || getMimeType(extension),
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -556,7 +515,7 @@ async function handleGenericTextBuffer(
|
||||
fileType: 'text/plain',
|
||||
size: 0,
|
||||
hash: '',
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -584,7 +543,7 @@ function handleGenericBuffer(
|
||||
fileType: fileType || getMimeType(extension),
|
||||
size: fileBuffer.length,
|
||||
hash: createHash('md5').update(fileBuffer).digest('hex'),
|
||||
processingTime: 0, // Will be set by caller
|
||||
processingTime: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -594,25 +553,11 @@ function handleGenericBuffer(
|
||||
*/
|
||||
async function parseBufferAsPdf(buffer: Buffer) {
|
||||
try {
|
||||
// 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')
|
||||
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
|
||||
const parser = new PdfParser()
|
||||
logger.info('Using main PDF parser for 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)
|
||||
}
|
||||
return await parser.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
throw new Error(`PDF parsing failed: ${(error as Error).message}`)
|
||||
}
|
||||
@@ -655,7 +600,7 @@ Please use a PDF viewer for best results.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error message for PDF parsing failure
|
||||
* Create error message for PDF parsing failure and make it more readable
|
||||
*/
|
||||
function createPdfFailureMessage(
|
||||
pageCount: number,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, 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()
|
||||
@@ -19,7 +25,7 @@ describe('/api/files/presigned', () => {
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
test('should return error when cloud storage is not enabled', async () => {
|
||||
it('should return error when cloud storage is not enabled', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 's3',
|
||||
@@ -39,7 +45,7 @@ describe('/api/files/presigned', () => {
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
|
||||
expect(response.status).toBe(500)
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,14 @@ 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({})
|
||||
})
|
||||
@@ -45,6 +53,7 @@ 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')
|
||||
@@ -74,6 +83,7 @@ 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')
|
||||
@@ -93,6 +103,7 @@ 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',
|
||||
},
|
||||
@@ -108,6 +119,7 @@ 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' },
|
||||
},
|
||||
@@ -123,6 +135,7 @@ 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>',
|
||||
@@ -141,6 +154,7 @@ 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',
|
||||
@@ -178,6 +192,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
useLocalVM: true,
|
||||
params: gmailData,
|
||||
})
|
||||
|
||||
@@ -200,6 +215,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
useLocalVM: true,
|
||||
params: complexEmailData,
|
||||
})
|
||||
|
||||
@@ -214,6 +230,7 @@ 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',
|
||||
},
|
||||
@@ -245,6 +262,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should handle timeout parameter', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
useLocalVM: true,
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
@@ -262,6 +280,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should handle empty parameters object', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "no params"',
|
||||
useLocalVM: true,
|
||||
params: {},
|
||||
})
|
||||
|
||||
@@ -295,6 +314,7 @@ 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,
|
||||
})
|
||||
|
||||
@@ -338,6 +358,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = null;\nreturn obj.someMethod();',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -379,6 +400,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const x = 42;\nreturn undefinedVariable + x;',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -409,6 +431,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test";',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -445,6 +468,7 @@ 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,
|
||||
})
|
||||
|
||||
@@ -476,6 +500,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = {\n name: "test"\n// Missing closing brace',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -496,6 +521,7 @@ 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',
|
||||
},
|
||||
@@ -512,6 +538,7 @@ 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',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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'
|
||||
@@ -8,6 +11,10 @@ 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
|
||||
*/
|
||||
@@ -124,6 +131,103 @@ 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
|
||||
*/
|
||||
@@ -442,6 +546,8 @@ export async function POST(req: NextRequest) {
|
||||
code,
|
||||
params = {},
|
||||
timeout = 5000,
|
||||
language = DEFAULT_CODE_LANGUAGE,
|
||||
useLocalVM = false,
|
||||
envVars = {},
|
||||
blockData = {},
|
||||
blockNameMapping = {},
|
||||
@@ -474,19 +580,164 @@ export async function POST(req: NextRequest) {
|
||||
resolvedCode = codeResolution.resolvedCode
|
||||
const contextVariables = codeResolution.contextVariables
|
||||
|
||||
const executionMethod = 'vm' // Default execution method
|
||||
const e2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
const useE2B =
|
||||
e2bEnabled &&
|
||||
!useLocalVM &&
|
||||
!isCustomTool &&
|
||||
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
|
||||
|
||||
logger.info(`[${requestId}] Using VM for code execution`, {
|
||||
hasEnvVars: Object.keys(envVars).length > 0,
|
||||
hasWorkflowVariables: Object.keys(workflowVariables).length > 0,
|
||||
})
|
||||
if (useE2B) {
|
||||
logger.info(`[${requestId}] E2B status`, {
|
||||
enabled: e2bEnabled,
|
||||
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
||||
language: lang,
|
||||
})
|
||||
let prologue = ''
|
||||
const epilogue = ''
|
||||
|
||||
// Create a secure context with console logging
|
||||
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'
|
||||
const context = createContext({
|
||||
params: executionParams,
|
||||
environmentVariables: envVars,
|
||||
...contextVariables, // Add resolved variables directly to context
|
||||
fetch: globalThis.fetch || require('node-fetch').default,
|
||||
...contextVariables,
|
||||
fetch: (globalThis as any).fetch || require('node-fetch').default,
|
||||
console: {
|
||||
log: (...args: any[]) => {
|
||||
const logMessage = `${args
|
||||
@@ -504,23 +755,17 @@ 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 // +1 because user code starts on next line
|
||||
|
||||
// Build the complete script with proper formatting for line numbers
|
||||
userCodeStartLine = wrapperLines.length + 1
|
||||
const fullScript = [
|
||||
...wrapperLines,
|
||||
` ${resolvedCode.split('\n').join('\n ')}`, // Indent user code
|
||||
` ${resolvedCode.split('\n').join('\n ')}`,
|
||||
' } catch (error) {',
|
||||
' console.error(error);',
|
||||
' throw error;',
|
||||
@@ -529,33 +774,26 @@ export async function POST(req: NextRequest) {
|
||||
].join('\n')
|
||||
|
||||
const script = new Script(fullScript, {
|
||||
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
|
||||
filename: 'user-function.js',
|
||||
lineOffset: 0,
|
||||
columnOffset: 0,
|
||||
})
|
||||
|
||||
const result = await script.runInContext(context, {
|
||||
timeout,
|
||||
displayErrors: true,
|
||||
breakOnSigint: true, // Allow breaking on SIGINT for better debugging
|
||||
breakOnSigint: true,
|
||||
})
|
||||
// }
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
|
||||
executionTime,
|
||||
})
|
||||
|
||||
const response = {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
result,
|
||||
stdout,
|
||||
executionTime,
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
output: { result, stdout, executionTime },
|
||||
})
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Function execution failed`, {
|
||||
|
||||
@@ -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/constants/knowledge'
|
||||
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/consts'
|
||||
import {
|
||||
cleanupUnusedTagDefinitions,
|
||||
createOrUpdateTagDefinitionsBulk,
|
||||
|
||||
@@ -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/constants/knowledge'
|
||||
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/consts'
|
||||
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { TAG_SLOTS } from '@/lib/knowledge/consts'
|
||||
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('FrozenCanvasAPI')
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
try {
|
||||
const { executionId } = await params
|
||||
|
||||
logger.debug(`Fetching frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Fetching execution data for: ${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 frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Successfully fetched execution data for: ${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 frozen canvas data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 })
|
||||
logger.error('Error fetching execution data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getEmailSubject,
|
||||
@@ -17,9 +17,17 @@ 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, workspace, workspaceInvitation } from '@/db/schema'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationsAPI')
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
@@ -40,7 +48,6 @@ 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)
|
||||
@@ -61,7 +68,6 @@ 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,
|
||||
@@ -118,10 +124,8 @@ 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 })
|
||||
}
|
||||
@@ -130,7 +134,6 @@ 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)
|
||||
@@ -148,7 +151,6 @@ 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)
|
||||
|
||||
@@ -167,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
// Validate seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
|
||||
|
||||
if (!seatValidation.canInvite) {
|
||||
@@ -185,7 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization details
|
||||
const organizationEntry = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
@@ -196,7 +196,6 @@ 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()
|
||||
@@ -209,11 +208,9 @@ 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) {
|
||||
@@ -229,7 +226,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
@@ -239,7 +235,6 @@ 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)
|
||||
@@ -265,7 +260,6 @@ 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(),
|
||||
@@ -280,10 +274,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()
|
||||
@@ -297,6 +291,7 @@ 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(),
|
||||
@@ -307,7 +302,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Send invitation emails
|
||||
const inviter = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
@@ -320,7 +314,6 @@ 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,
|
||||
@@ -346,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
@@ -359,7 +352,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}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
@@ -446,7 +439,6 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -464,12 +456,9 @@ 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),
|
||||
@@ -486,6 +475,23 @@ 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,
|
||||
|
||||
@@ -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}/api/organizations/invitations/accept?id=${invitationId}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
)
|
||||
|
||||
|
||||
@@ -1,373 +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 { 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 })
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
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'
|
||||
@@ -17,17 +19,10 @@ import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
environment as environmentTable,
|
||||
subscription,
|
||||
userStats,
|
||||
workflow,
|
||||
workflowSchedule,
|
||||
} from '@/db/schema'
|
||||
import { userStats, workflow, workflowSchedule } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
import type { SubscriptionPlan } from '@/services/queue/types'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
// Add dynamic export to prevent caching
|
||||
@@ -113,19 +108,13 @@ export async function GET() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check rate limits for scheduled execution
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, workflowRecord.userId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
// Check rate limits for scheduled execution (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(workflowRecord.userId)
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
workflowRecord.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'schedule',
|
||||
false // schedules are always sync
|
||||
)
|
||||
@@ -236,20 +225,15 @@ export async function GET() {
|
||||
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// 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 ?? {})
|
||||
// Retrieve environment variables with workspace precedence
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
workflowRecord.userId,
|
||||
workflowRecord.workspaceId || undefined
|
||||
)
|
||||
const variables = EnvVarsSchema.parse({
|
||||
...personalEncrypted,
|
||||
...workspaceEncrypted,
|
||||
})
|
||||
|
||||
const currentBlockStates = await Object.entries(mergedStates).reduce(
|
||||
async (accPromise, [id, block]) => {
|
||||
|
||||
@@ -85,7 +85,8 @@ export async function POST(request: Request) {
|
||||
|
||||
logger.info(`Fetching all Discord channels for server: ${serverId}`)
|
||||
|
||||
// Fetch all channels from Discord API
|
||||
// Listing guild channels with a bot token is allowed if the bot is in the guild.
|
||||
// Keep the request, but if unauthorized, return an empty list so the selector doesn't hard fail.
|
||||
const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}/channels`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -95,20 +96,14 @@ export async function POST(request: Request) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Discord API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch channels (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch channels: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
logger.warn(
|
||||
'Discord API returned non-OK for channels; returning empty list to avoid UX break',
|
||||
{
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
)
|
||||
return NextResponse.json({ channels: [] })
|
||||
}
|
||||
|
||||
const channels = (await response.json()) as DiscordChannel[]
|
||||
|
||||
@@ -64,46 +64,14 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise, fetch all servers the bot is in
|
||||
logger.info('Fetching all Discord servers')
|
||||
|
||||
const response = await fetch('https://discord.com/api/v10/users/@me/guilds', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Discord API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch servers (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch servers: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const servers = (await response.json()) as DiscordServer[]
|
||||
logger.info(`Successfully fetched ${servers.length} servers`)
|
||||
|
||||
return NextResponse.json({
|
||||
servers: servers.map((server: DiscordServer) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
icon: server.icon
|
||||
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`
|
||||
: null,
|
||||
})),
|
||||
})
|
||||
// Listing guilds via REST requires a user OAuth2 access token with the 'guilds' scope.
|
||||
// A bot token cannot call /users/@me/guilds and will return 401.
|
||||
// Since this selector only has a bot token, return an empty list instead of erroring
|
||||
// and let users provide a Server ID in advanced mode.
|
||||
logger.info(
|
||||
'Skipping guild listing: bot token cannot list /users/@me/guilds; returning empty list'
|
||||
)
|
||||
return NextResponse.json({ servers: [] })
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -6,17 +6,32 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('JiraIssuesAPI')
|
||||
|
||||
// Helper functions
|
||||
const createErrorResponse = async (response: Response, defaultMessage: string) => {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.message || errorData.errorMessages?.[0] || defaultMessage
|
||||
} catch {
|
||||
return defaultMessage
|
||||
}
|
||||
}
|
||||
|
||||
const validateRequiredParams = (domain: string | null, accessToken: string | null) => {
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
const validationError = validateRequiredParams(domain || null, accessToken || null)
|
||||
if (validationError) return validationError
|
||||
|
||||
if (issueKeys.length === 0) {
|
||||
logger.info('No issue keys provided, returning empty result')
|
||||
@@ -24,7 +39,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
|
||||
|
||||
// Build the URL using cloudId for Jira API
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch`
|
||||
@@ -53,47 +68,24 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', JSON.stringify(errorData, null, 2))
|
||||
errorMessage = errorData.message || `Failed to fetch Jira issues (${response.status})`
|
||||
} catch (e) {
|
||||
logger.error('Could not parse error response as JSON:', e)
|
||||
|
||||
try {
|
||||
const _text = await response.text()
|
||||
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
|
||||
} catch (_textError) {
|
||||
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch Jira issues (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const issues = (data.issues || []).map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.fields.summary,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
modifiedTime: issue.fields.updated,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
|
||||
if (data.issues && data.issues.length > 0) {
|
||||
data.issues.slice(0, 3).forEach((issue: any) => {
|
||||
logger.info(`- ${issue.key}: ${issue.fields.summary}`)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
issues: data.issues
|
||||
? data.issues.map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.fields.summary,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
modifiedTime: issue.fields.updated,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
: [],
|
||||
cloudId, // Return the cloudId so it can be cached
|
||||
})
|
||||
return NextResponse.json({ issues, cloudId })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issues:', error)
|
||||
return NextResponse.json(
|
||||
@@ -111,83 +103,79 @@ export async function GET(request: Request) {
|
||||
const providedCloudId = url.searchParams.get('cloudId')
|
||||
const query = url.searchParams.get('query') || ''
|
||||
const projectId = url.searchParams.get('projectId') || ''
|
||||
const manualProjectId = url.searchParams.get('manualProjectId') || ''
|
||||
const all = url.searchParams.get('all')?.toLowerCase() === 'true'
|
||||
const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10)
|
||||
const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
logger.info('Using cloud ID:', cloudId)
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Only add query if it exists
|
||||
if (query) {
|
||||
params.append('query', query)
|
||||
}
|
||||
const validationError = validateRequiredParams(domain || null, accessToken || null)
|
||||
if (validationError) return validationError
|
||||
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
|
||||
let data: any
|
||||
|
||||
if (query) {
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
|
||||
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
|
||||
const params = new URLSearchParams({ query })
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params}`
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
logger.info('Response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.message || `Failed to fetch issue suggestions (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
|
||||
}
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch issue suggestions (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
data = await response.json()
|
||||
} else if (projectId) {
|
||||
// When no query, list latest issues for the selected project using Search API
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
|
||||
searchParams.append('maxResults', '25')
|
||||
searchParams.append('fields', 'summary,key')
|
||||
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
|
||||
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira Search API error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
} else if (projectId || manualProjectId) {
|
||||
const SAFETY_CAP = 1000
|
||||
const PAGE_SIZE = 100
|
||||
const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP)
|
||||
const projectKey = (projectId || manualProjectId).trim()
|
||||
|
||||
const buildSearchUrl = (startAt: number) => {
|
||||
const params = new URLSearchParams({
|
||||
jql: `project=${projectKey} ORDER BY updated DESC`,
|
||||
maxResults: String(Math.min(PAGE_SIZE, target)),
|
||||
startAt: String(startAt),
|
||||
fields: 'summary,key,updated',
|
||||
})
|
||||
return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}`
|
||||
}
|
||||
const searchData = await response.json()
|
||||
const issues = (searchData.issues || []).map((it: any) => ({
|
||||
|
||||
let startAt = 0
|
||||
let collected: any[] = []
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const response = await fetch(buildSearchUrl(startAt), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch issues (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const page = await response.json()
|
||||
const issues = page.issues || []
|
||||
total = page.total || issues.length
|
||||
collected = collected.concat(issues)
|
||||
startAt += PAGE_SIZE
|
||||
} while (all && collected.length < Math.min(total, target))
|
||||
|
||||
const issues = collected.slice(0, target).map((it: any) => ({
|
||||
key: it.key,
|
||||
summary: it.fields?.summary || it.key,
|
||||
}))
|
||||
@@ -196,10 +184,7 @@ export async function GET(request: Request) {
|
||||
data = { sections: [], cloudId }
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
cloudId, // Return the cloudId so it can be cached
|
||||
})
|
||||
return NextResponse.json({ ...data, cloudId })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issue suggestions:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -42,10 +42,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!issueType) {
|
||||
logger.error('Missing issue type in request')
|
||||
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
|
||||
}
|
||||
const normalizedIssueType = issueType || 'Task'
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
@@ -62,7 +59,7 @@ export async function POST(request: Request) {
|
||||
id: projectId,
|
||||
},
|
||||
issuetype: {
|
||||
name: issueType,
|
||||
name: normalizedIssueType,
|
||||
},
|
||||
summary: summary,
|
||||
}
|
||||
|
||||
114
apps/sim/app/api/tools/mongodb/delete/route.ts
Normal file
114
apps/sim/app/api/tools/mongodb/delete/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
102
apps/sim/app/api/tools/mongodb/execute/route.ts
Normal file
102
apps/sim/app/api/tools/mongodb/execute/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
98
apps/sim/app/api/tools/mongodb/insert/route.ts
Normal file
98
apps/sim/app/api/tools/mongodb/insert/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
136
apps/sim/app/api/tools/mongodb/query/route.ts
Normal file
136
apps/sim/app/api/tools/mongodb/query/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
143
apps/sim/app/api/tools/mongodb/update/route.ts
Normal file
143
apps/sim/app/api/tools/mongodb/update/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
123
apps/sim/app/api/tools/mongodb/utils.ts
Normal file
123
apps/sim/app/api/tools/mongodb/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,13 +12,13 @@ const DeleteSchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,12 +12,12 @@ const ExecuteSchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -26,7 +27,6 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
// Validate query before execution
|
||||
const validation = validateQuery(params.query)
|
||||
if (!validation.isValid) {
|
||||
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,7 +12,7 @@ const InsertSchema = z.object({
|
||||
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('required'),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
table: z.string().min(1, 'Table name is required'),
|
||||
data: z.union([
|
||||
z
|
||||
@@ -38,13 +39,10 @@ const InsertSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data)
|
||||
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,12 +12,12 @@ const QuerySchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -26,7 +27,6 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing MySQL query on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
// Validate query before execution
|
||||
const validation = validateQuery(params.query)
|
||||
if (!validation.isValid) {
|
||||
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,7 +12,7 @@ const UpdateSchema = z.object({
|
||||
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('required'),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
table: z.string().min(1, 'Table name is required'),
|
||||
data: z.union([
|
||||
z
|
||||
@@ -36,7 +37,7 @@ const UpdateSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface MySQLConnectionConfig {
|
||||
database: string
|
||||
username: string
|
||||
password: string
|
||||
ssl?: string
|
||||
ssl?: 'disabled' | 'required' | 'preferred'
|
||||
}
|
||||
|
||||
export async function createMySQLConnection(config: MySQLConnectionConfig) {
|
||||
@@ -18,13 +18,13 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) {
|
||||
password: config.password,
|
||||
}
|
||||
|
||||
// Handle SSL configuration
|
||||
if (config.ssl === 'required') {
|
||||
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 }
|
||||
}
|
||||
// For 'disabled', we don't set the ssl property at all
|
||||
|
||||
return mysql.createConnection(connectionConfig)
|
||||
}
|
||||
@@ -54,7 +54,6 @@ export async function executeQuery(
|
||||
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,
|
||||
@@ -91,7 +90,6 @@ export function validateQuery(query: string): { isValid: boolean; error?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow specific statement types for execute endpoint
|
||||
const allowedStatements = /^(select|insert|update|delete|with|show|describe|explain)\s+/i
|
||||
if (!allowedStatements.test(trimmedQuery)) {
|
||||
return {
|
||||
@@ -116,6 +114,8 @@ export function buildInsertQuery(table: string, data: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -127,14 +127,33 @@ export function buildUpdateQuery(table: string, data: Record<string, unknown>, w
|
||||
}
|
||||
|
||||
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 {
|
||||
// Handle schema.table format
|
||||
if (identifier.includes('.')) {
|
||||
const parts = identifier.split('.')
|
||||
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
|
||||
@@ -144,16 +163,13 @@ export function sanitizeIdentifier(identifier: string): string {
|
||||
}
|
||||
|
||||
function sanitizeSingleIdentifier(identifier: string): string {
|
||||
// Remove any existing backticks to prevent double-escaping
|
||||
const cleaned = identifier.replace(/`/g, '')
|
||||
|
||||
// Validate identifier contains only safe characters
|
||||
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.`
|
||||
)
|
||||
}
|
||||
|
||||
// Wrap in backticks for MySQL
|
||||
return `\`${cleaned}\``
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
buildDeleteQuery,
|
||||
createPostgresConnection,
|
||||
executeQuery,
|
||||
} from '@/app/api/tools/postgresql/utils'
|
||||
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLDeleteAPI')
|
||||
|
||||
@@ -15,13 +12,13 @@ const DeleteSchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -31,7 +28,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const client = await createPostgresConnection({
|
||||
const sql = createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
@@ -41,8 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
const { query, values } = buildDeleteQuery(params.table, params.where)
|
||||
const result = await executeQuery(client, query, values)
|
||||
const result = await executeDelete(sql, params.table, params.where)
|
||||
|
||||
logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`)
|
||||
|
||||
@@ -52,7 +48,7 @@ export async function POST(request: NextRequest) {
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} finally {
|
||||
await client.end()
|
||||
await sql.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -15,12 +16,12 @@ const ExecuteSchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -30,7 +31,6 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
// Validate query before execution
|
||||
const validation = validateQuery(params.query)
|
||||
if (!validation.isValid) {
|
||||
logger.warn(`[${requestId}] Query validation failed: ${validation.error}`)
|
||||
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const client = await createPostgresConnection({
|
||||
const sql = createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeQuery(client, params.query)
|
||||
const result = await executeQuery(sql, params.query)
|
||||
|
||||
logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`)
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} finally {
|
||||
await client.end()
|
||||
await sql.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
buildInsertQuery,
|
||||
createPostgresConnection,
|
||||
executeQuery,
|
||||
} from '@/app/api/tools/postgresql/utils'
|
||||
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLInsertAPI')
|
||||
|
||||
@@ -15,7 +12,7 @@ const InsertSchema = z.object({
|
||||
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('required'),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
table: z.string().min(1, 'Table name is required'),
|
||||
data: z.union([
|
||||
z
|
||||
@@ -42,21 +39,18 @@ const InsertSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Debug: Log the data field to see what we're getting
|
||||
logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data)
|
||||
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const client = await createPostgresConnection({
|
||||
const sql = createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
@@ -66,8 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
const { query, values } = buildInsertQuery(params.table, params.data)
|
||||
const result = await executeQuery(client, query, values)
|
||||
const result = await executeInsert(sql, params.table, params.data)
|
||||
|
||||
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
|
||||
|
||||
@@ -77,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} finally {
|
||||
await client.end()
|
||||
await sql.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -11,12 +12,12 @@ const QuerySchema = z.object({
|
||||
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('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 = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const client = await createPostgresConnection({
|
||||
const sql = createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
@@ -36,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeQuery(client, params.query)
|
||||
const result = await executeQuery(sql, params.query)
|
||||
|
||||
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
|
||||
|
||||
@@ -46,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} finally {
|
||||
await client.end()
|
||||
await sql.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
buildUpdateQuery,
|
||||
createPostgresConnection,
|
||||
executeQuery,
|
||||
} from '@/app/api/tools/postgresql/utils'
|
||||
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLUpdateAPI')
|
||||
|
||||
@@ -15,7 +12,7 @@ const UpdateSchema = z.object({
|
||||
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('required'),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
table: z.string().min(1, 'Table name is required'),
|
||||
data: z.union([
|
||||
z
|
||||
@@ -40,7 +37,7 @@ const UpdateSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -50,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const client = await createPostgresConnection({
|
||||
const sql = createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
@@ -60,8 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
const { query, values } = buildUpdateQuery(params.table, params.data, params.where)
|
||||
const result = await executeQuery(client, query, values)
|
||||
const result = await executeUpdate(sql, params.table, params.data, params.where)
|
||||
|
||||
logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`)
|
||||
|
||||
@@ -71,7 +67,7 @@ export async function POST(request: NextRequest) {
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} finally {
|
||||
await client.end()
|
||||
await sql.end()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
import { Client } from 'pg'
|
||||
import postgres from 'postgres'
|
||||
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
|
||||
|
||||
export async function createPostgresConnection(config: PostgresConnectionConfig): Promise<Client> {
|
||||
const client = new Client({
|
||||
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,
|
||||
user: config.username,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
ssl:
|
||||
config.ssl === 'disabled'
|
||||
? false
|
||||
: config.ssl === 'required'
|
||||
? true
|
||||
: config.ssl === 'preferred'
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
connectionTimeoutMillis: 10000, // 10 seconds
|
||||
query_timeout: 30000, // 30 seconds
|
||||
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
|
||||
})
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
return client
|
||||
} catch (error) {
|
||||
await client.end()
|
||||
throw error
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
export async function executeQuery(
|
||||
client: Client,
|
||||
sql: any,
|
||||
query: string,
|
||||
params: unknown[] = []
|
||||
): Promise<{ rows: unknown[]; rowCount: number }> {
|
||||
const result = await client.query(query, params)
|
||||
const result = await sql.unsafe(query, params)
|
||||
return {
|
||||
rows: result.rows || [],
|
||||
rowCount: result.rowCount || 0,
|
||||
rows: Array.isArray(result) ? result : [result],
|
||||
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +82,6 @@ export function validateQuery(query: string): { isValid: boolean; error?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow specific statement types for execute endpoint
|
||||
const allowedStatements = /^(select|insert|update|delete|with|explain|analyze|show)\s+/i
|
||||
if (!allowedStatements.test(trimmedQuery)) {
|
||||
return {
|
||||
@@ -98,7 +95,6 @@ export function validateQuery(query: string): { isValid: boolean; error?: string
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(identifier: string): string {
|
||||
// Handle schema.table format
|
||||
if (identifier.includes('.')) {
|
||||
const parts = identifier.split('.')
|
||||
return parts.map((part) => sanitizeSingleIdentifier(part)).join('.')
|
||||
@@ -107,28 +103,41 @@ export function sanitizeIdentifier(identifier: string): string {
|
||||
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 {
|
||||
// Remove any existing double quotes to prevent double-escaping
|
||||
const cleaned = identifier.replace(/"/g, '')
|
||||
|
||||
// Validate identifier contains only safe characters
|
||||
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.`
|
||||
)
|
||||
}
|
||||
|
||||
// Wrap in double quotes for PostgreSQL
|
||||
return `"${cleaned}"`
|
||||
}
|
||||
|
||||
export function buildInsertQuery(
|
||||
export async function executeInsert(
|
||||
sql: any,
|
||||
table: string,
|
||||
data: Record<string, unknown>
|
||||
): {
|
||||
query: string
|
||||
values: unknown[]
|
||||
} {
|
||||
): Promise<{ rows: unknown[]; rowCount: number }> {
|
||||
const sanitizedTable = sanitizeIdentifier(table)
|
||||
const columns = Object.keys(data)
|
||||
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
|
||||
@@ -136,18 +145,22 @@ export function buildInsertQuery(
|
||||
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 { query, values }
|
||||
return {
|
||||
rows: Array.isArray(result) ? result : [result],
|
||||
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdateQuery(
|
||||
export async function executeUpdate(
|
||||
sql: any,
|
||||
table: string,
|
||||
data: Record<string, unknown>,
|
||||
where: string
|
||||
): {
|
||||
query: string
|
||||
values: unknown[]
|
||||
} {
|
||||
): Promise<{ rows: unknown[]; rowCount: number }> {
|
||||
validateWhereClause(where)
|
||||
|
||||
const sanitizedTable = sanitizeIdentifier(table)
|
||||
const columns = Object.keys(data)
|
||||
const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col))
|
||||
@@ -155,19 +168,27 @@ export function buildUpdateQuery(
|
||||
const values = columns.map((col) => data[col])
|
||||
|
||||
const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where} RETURNING *`
|
||||
const result = await sql.unsafe(query, values)
|
||||
|
||||
return { query, values }
|
||||
return {
|
||||
rows: Array.isArray(result) ? result : [result],
|
||||
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDeleteQuery(
|
||||
export async function executeDelete(
|
||||
sql: any,
|
||||
table: string,
|
||||
where: string
|
||||
): {
|
||||
query: string
|
||||
values: unknown[]
|
||||
} {
|
||||
): 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 { query, values: [] }
|
||||
return {
|
||||
rows: Array.isArray(result) ? result : [result],
|
||||
rowCount: Array.isArray(result) ? result.length : result ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
|
||||
import {
|
||||
getOrganizationBillingData,
|
||||
isOrganizationOwnerOrAdmin,
|
||||
} from '@/lib/billing/core/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
|
||||
|
||||
const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
const logger = createLogger('UnifiedUsageAPI')
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|organization&userId=<id>&organizationId=<id>
|
||||
* Unified Usage Endpoint
|
||||
* GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id>
|
||||
*
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -25,7 +27,6 @@ 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"' },
|
||||
@@ -33,7 +34,6 @@ 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,7 +41,6 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get usage limit info
|
||||
if (context === 'organization') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
@@ -107,10 +106,8 @@ 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' },
|
||||
@@ -123,10 +120,7 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Use the dedicated function to update org usage limit
|
||||
const { updateOrganizationUsageLimit } = await import(
|
||||
'@/lib/billing/core/organization-billing'
|
||||
)
|
||||
const { updateOrganizationUsageLimit } = await import('@/lib/billing/core/organization')
|
||||
const result = await updateOrganizationUsageLimit(organizationId, limit)
|
||||
|
||||
if (!result.success) {
|
||||
@@ -137,7 +131,6 @@ 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({
|
||||
@@ -1,25 +1,23 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, subscription } from '@/db/schema'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
|
||||
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)
|
||||
@@ -36,32 +34,22 @@ export async function GET(request: NextRequest) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
// Get user subscription
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, authenticatedUserId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as
|
||||
| 'free'
|
||||
| 'pro'
|
||||
| 'team'
|
||||
| 'enterprise'
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const isApiAuth = !session?.user?.id
|
||||
const triggerType = isApiAuth ? 'api' : 'manual'
|
||||
|
||||
const syncStatus = await rateLimiter.getRateLimitStatus(
|
||||
const syncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false
|
||||
)
|
||||
const asyncStatus = await rateLimiter.getRateLimitStatus(
|
||||
const asyncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
true
|
||||
)
|
||||
@@ -24,6 +24,7 @@ const SettingsSchema = z.object({
|
||||
unsubscribeNotifications: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
billingUsageNotificationsEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// Default settings values
|
||||
@@ -35,6 +36,7 @@ const defaultSettings = {
|
||||
consoleExpandedByDefault: true,
|
||||
telemetryEnabled: true,
|
||||
emailPreferences: {},
|
||||
billingUsageNotificationsEnabled: true,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -68,6 +70,7 @@ export async function GET() {
|
||||
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
|
||||
telemetryEnabled: userSettings.telemetryEnabled,
|
||||
emailPreferences: userSettings.emailPreferences ?? {},
|
||||
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 = 'edge'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('WandGenerateAPI')
|
||||
@@ -47,6 +52,89 @@ interface RequestBody {
|
||||
systemPrompt?: string
|
||||
stream?: boolean
|
||||
history?: ChatMessage[]
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -64,7 +152,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as RequestBody
|
||||
|
||||
const { prompt, systemPrompt, stream = false, history = [] } = body
|
||||
const { prompt, systemPrompt, stream = false, history = [], workflowId } = body
|
||||
|
||||
if (!prompt) {
|
||||
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
|
||||
@@ -74,18 +162,14 @@ 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(
|
||||
@@ -99,7 +183,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
)
|
||||
|
||||
// For streaming responses
|
||||
if (stream) {
|
||||
try {
|
||||
logger.debug(
|
||||
@@ -110,124 +193,167 @@ export async function POST(req: NextRequest) {
|
||||
`[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`
|
||||
)
|
||||
|
||||
// Add AbortController with timeout
|
||||
const abortController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort('Stream timeout after 30 seconds')
|
||||
}, 30000)
|
||||
const apiUrl = useWandAzure
|
||||
? `${azureEndpoint}/openai/deployments/${wandModelName}/chat/completions?api-version=${azureApiVersion}`
|
||||
: 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
// Forward request abort signal if available
|
||||
req.signal?.addEventListener('abort', () => {
|
||||
abortController.abort('Request cancelled by client')
|
||||
})
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const streamCompletion = await client.chat.completions.create(
|
||||
{
|
||||
if (useWandAzure) {
|
||||
headers['api-key'] = azureApiKey!
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${openaiApiKey}`
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Making streaming request to: ${apiUrl}`)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
temperature: 0.2,
|
||||
max_tokens: 10000,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
},
|
||||
{
|
||||
signal: abortController.signal, // Add AbortSignal
|
||||
}
|
||||
)
|
||||
|
||||
clearTimeout(timeoutId) // Clear timeout after successful creation
|
||||
logger.info(`[${requestId}] Stream created successfully, starting reader pattern`)
|
||||
|
||||
logger.debug(`[${requestId}] Stream connection established successfully`)
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Starting streaming with timeout protection`)
|
||||
let chunkCount = 0
|
||||
let hasUsageData = false
|
||||
|
||||
// Use for await with AbortController timeout protection
|
||||
for await (const chunk of streamCompletion) {
|
||||
chunkCount++
|
||||
|
||||
if (chunkCount === 1) {
|
||||
logger.info(`[${requestId}] Received first chunk via for await`)
|
||||
}
|
||||
|
||||
// Process the chunk
|
||||
const content = chunk.choices?.[0]?.delta?.content || ''
|
||||
if (content) {
|
||||
// Use SSE format identical to chat streaming
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
// Check for usage data
|
||||
if (chunk.usage) {
|
||||
hasUsageData = true
|
||||
logger.info(
|
||||
`[${requestId}] Received usage data: ${JSON.stringify(chunk.usage)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Log every 5th chunk to avoid spam
|
||||
if (chunkCount % 5 === 0) {
|
||||
logger.debug(`[${requestId}] Processed ${chunkCount} chunks so far`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reader pattern completed. Total chunks: ${chunkCount}, Usage data received: ${hasUsageData}`
|
||||
)
|
||||
|
||||
// Send completion signal in SSE format
|
||||
logger.info(`[${requestId}] Sending completion signal`)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
|
||||
|
||||
logger.info(`[${requestId}] Closing controller`)
|
||||
controller.close()
|
||||
|
||||
logger.info(`[${requestId}] Wand generation streaming completed successfully`)
|
||||
} catch (streamError: any) {
|
||||
if (streamError.name === 'AbortError') {
|
||||
logger.info(
|
||||
`[${requestId}] Stream was aborted (timeout or cancel): ${streamError.message}`
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ error: 'Stream cancelled', done: true })}\n\n`
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.error(`[${requestId}] Streaming error`, { error: streamError.message })
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
|
||||
)
|
||||
)
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`[${requestId}] API request failed`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Stream response received, starting processing`)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const readable = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let buffer = ''
|
||||
let chunkCount = 0
|
||||
let finalUsage: any = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
logger.info(`[${requestId}] Stream completed. Total chunks: ${chunkCount}`)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
|
||||
if (data === '[DONE]') {
|
||||
logger.info(`[${requestId}] Received [DONE] signal`)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
|
||||
if (content) {
|
||||
chunkCount++
|
||||
if (chunkCount === 1) {
|
||||
logger.info(`[${requestId}] Received first content chunk`)
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
finalUsage = parsed.usage
|
||||
logger.info(
|
||||
`[${requestId}] Received usage data: ${JSON.stringify(parsed.usage)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (chunkCount % 10 === 0) {
|
||||
logger.debug(`[${requestId}] Processed ${chunkCount} chunks`)
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.debug(
|
||||
`[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
message: streamError?.message || 'Unknown error',
|
||||
stack: streamError?.stack,
|
||||
})
|
||||
|
||||
const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
|
||||
controller.enqueue(encoder.encode(errorData))
|
||||
controller.close()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Streaming error`, {
|
||||
error: error.message || 'Unknown error',
|
||||
stack: error.stack,
|
||||
logger.error(`[${requestId}] Failed to create stream`, {
|
||||
name: error?.name,
|
||||
message: error?.message || 'Unknown error',
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
responseStatus: error?.response?.status,
|
||||
responseData: error?.response?.data ? safeStringify(error.response.data) : undefined,
|
||||
stack: error?.stack,
|
||||
useWandAzure,
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
endpoint: useWandAzure ? azureEndpoint : 'api.openai.com',
|
||||
apiVersion: useWandAzure ? azureApiVersion : 'N/A',
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -237,7 +363,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
const completion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
@@ -258,11 +383,27 @@ 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`, {
|
||||
error: error.message || 'Unknown error',
|
||||
stack: error.stack,
|
||||
name: error?.name,
|
||||
message: error?.message || 'Unknown error',
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
responseStatus: error instanceof OpenAI.APIError ? error.status : error?.response?.status,
|
||||
responseData: (error as any)?.response?.data
|
||||
? safeStringify((error as any).response.data)
|
||||
: undefined,
|
||||
stack: error?.stack,
|
||||
useWandAzure,
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
endpoint: useWandAzure ? azureEndpoint : 'api.openai.com',
|
||||
apiVersion: useWandAzure ? azureApiVersion : 'N/A',
|
||||
})
|
||||
|
||||
let clientErrorMessage = 'Wand generation failed. Please try again later.'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -11,9 +12,8 @@ import {
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { db } from '@/db'
|
||||
import { subscription, webhook, workflow } from '@/db/schema'
|
||||
import { webhook, workflow } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
import type { SubscriptionPlan } from '@/services/queue/types'
|
||||
|
||||
const logger = createLogger('WebhookTriggerAPI')
|
||||
|
||||
@@ -248,20 +248,14 @@ export async function POST(
|
||||
|
||||
// --- PHASE 3: Rate limiting for webhook execution ---
|
||||
try {
|
||||
// Get user subscription for rate limiting
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, foundWorkflow.userId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
// Get user subscription for rate limiting (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(foundWorkflow.userId)
|
||||
|
||||
// Check async rate limits (webhooks are processed asynchronously)
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
foundWorkflow.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'webhook',
|
||||
true // isAsync = true for webhook execution
|
||||
)
|
||||
|
||||
@@ -195,14 +195,31 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// Process blocks
|
||||
blocks.forEach((block) => {
|
||||
const parentId = block.parentId || null
|
||||
const extent = block.extent || null
|
||||
const blockData = {
|
||||
...(block.data || {}),
|
||||
...(parentId && { parentId }),
|
||||
...(extent && { extent }),
|
||||
}
|
||||
|
||||
blocksMap[block.id] = {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: { x: Number(block.positionX), y: Number(block.positionY) },
|
||||
data: block.data,
|
||||
data: blockData,
|
||||
enabled: block.enabled,
|
||||
subBlocks: block.subBlocks || {},
|
||||
// Preserve execution-relevant flags so serializer behavior matches manual runs
|
||||
isWide: block.isWide ?? false,
|
||||
advancedMode: block.advancedMode ?? false,
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
outputs: block.outputs || {},
|
||||
horizontalHandles: block.horizontalHandles ?? true,
|
||||
height: Number(block.height || 0),
|
||||
parentId,
|
||||
extent,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +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 { Variable } from '@/stores/panel/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
@@ -97,7 +98,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
variables: source.variables || {},
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
@@ -46,6 +46,11 @@ describe('Workflow Execution API Route', () => {
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
checkRateLimitWithSubscription: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
})),
|
||||
RateLimitError: class RateLimitError extends Error {
|
||||
constructor(
|
||||
@@ -66,6 +71,13 @@ describe('Workflow Execution API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn().mockResolvedValue({
|
||||
plan: 'free',
|
||||
referenceId: 'user-id',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
subscription: {
|
||||
plan: 'plan',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
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,15 +20,10 @@ import {
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment as environmentTable, subscription, userStats } from '@/db/schema'
|
||||
import { userStats } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import {
|
||||
RateLimitError,
|
||||
RateLimiter,
|
||||
type SubscriptionPlan,
|
||||
type TriggerType,
|
||||
} from '@/services/queue'
|
||||
import { RateLimitError, RateLimiter, type TriggerType } from '@/services/queue'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
const logger = createLogger('WorkflowExecuteAPI')
|
||||
@@ -64,7 +61,12 @@ class UsageLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflow(workflow: any, requestId: string, input?: any): Promise<any> {
|
||||
async function executeWorkflow(
|
||||
workflow: any,
|
||||
requestId: string,
|
||||
input?: any,
|
||||
executingUserId?: string
|
||||
): Promise<any> {
|
||||
const workflowId = workflow.id
|
||||
const executionId = uuidv4()
|
||||
|
||||
@@ -127,23 +129,15 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
|
||||
// Use the same execution flow as in scheduled executions
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// 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 ?? {})
|
||||
// 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 })
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: workflow.userId,
|
||||
userId: executingUserId || workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
variables,
|
||||
})
|
||||
@@ -376,19 +370,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
try {
|
||||
// Check rate limits BEFORE entering queue for GET requests
|
||||
if (triggerType === 'api') {
|
||||
// Get user subscription
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, validation.workflow.userId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(validation.workflow.userId)
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
validation.workflow.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // isAsync = false for sync calls
|
||||
)
|
||||
@@ -400,7 +388,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, undefined)
|
||||
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
|
||||
)
|
||||
|
||||
// Check if the workflow execution contains a response block output
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
@@ -501,20 +495,15 @@ export async function POST(
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, authenticatedUserId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
|
||||
if (isAsync) {
|
||||
try {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'api',
|
||||
true // isAsync = true
|
||||
)
|
||||
@@ -576,9 +565,9 @@ export async function POST(
|
||||
|
||||
try {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // isAsync = false for sync calls
|
||||
)
|
||||
@@ -589,7 +578,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, input)
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
input,
|
||||
authenticatedUserId
|
||||
)
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowStatusAPI')
|
||||
|
||||
@@ -24,72 +22,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
// Check if the workflow has meaningful changes that would require redeployment
|
||||
let needsRedeployment = false
|
||||
if (validation.workflow.isDeployed && validation.workflow.deployedState) {
|
||||
// Get current state from normalized tables (same logic as deployment API)
|
||||
const blocks = await db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, id))
|
||||
|
||||
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id))
|
||||
|
||||
const subflows = await db
|
||||
.select()
|
||||
.from(workflowSubflows)
|
||||
.where(eq(workflowSubflows.workflowId, id))
|
||||
|
||||
// Build current state from normalized data
|
||||
const blocksMap: Record<string, any> = {}
|
||||
const loops: Record<string, any> = {}
|
||||
const parallels: Record<string, any> = {}
|
||||
|
||||
// Process blocks
|
||||
blocks.forEach((block) => {
|
||||
blocksMap[block.id] = {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: { x: Number(block.positionX), y: Number(block.positionY) },
|
||||
data: block.data,
|
||||
enabled: block.enabled,
|
||||
subBlocks: block.subBlocks || {},
|
||||
}
|
||||
})
|
||||
|
||||
// Process subflows (loops and parallels)
|
||||
subflows.forEach((subflow) => {
|
||||
const config = (subflow.config as any) || {}
|
||||
if (subflow.type === 'loop') {
|
||||
loops[subflow.id] = {
|
||||
id: subflow.id,
|
||||
nodes: config.nodes || [],
|
||||
iterations: config.iterations || 1,
|
||||
loopType: config.loopType || 'for',
|
||||
forEachItems: config.forEachItems || '',
|
||||
}
|
||||
} else if (subflow.type === 'parallel') {
|
||||
parallels[subflow.id] = {
|
||||
id: subflow.id,
|
||||
nodes: config.nodes || [],
|
||||
count: config.count || 2,
|
||||
distribution: config.distribution || '',
|
||||
parallelType: config.parallelType || 'count',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Convert edges to the expected format
|
||||
const edgesArray = edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.sourceBlockId,
|
||||
target: edge.targetBlockId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'default',
|
||||
data: {},
|
||||
}))
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(id)
|
||||
const currentState = {
|
||||
blocks: blocksMap,
|
||||
edges: edgesArray,
|
||||
loops,
|
||||
parallels,
|
||||
blocks: normalizedData?.blocks || {},
|
||||
edges: normalizedData?.edges || [],
|
||||
loops: normalizedData?.loops || {},
|
||||
parallels: normalizedData?.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 } from '@/db/schema'
|
||||
import { workflow, workflowBlocks, workspace } from '@/db/schema'
|
||||
import { verifyWorkspaceMembership } from './utils'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
@@ -16,6 +18,68 @@ 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)
|
||||
@@ -36,114 +100,7 @@ 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,
|
||||
@@ -163,7 +120,6 @@ 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,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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)
|
||||
@@ -1,4 +1,8 @@
|
||||
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(
|
||||
@@ -13,3 +17,23 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,19 @@ 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 // Single permission type instead of object with booleans
|
||||
permissions: PermissionType
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -33,7 +36,6 @@ 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)
|
||||
@@ -57,7 +59,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
total: result.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace permissions:', error)
|
||||
logger.error('Error fetching workspace permissions:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -81,7 +83,6 @@ 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) {
|
||||
@@ -91,10 +92,8 @@ 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(
|
||||
@@ -103,10 +102,8 @@ 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(
|
||||
@@ -117,7 +114,6 @@ 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,
|
||||
@@ -138,7 +134,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
total: updatedUsers.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating workspace permissions:', error)
|
||||
logger.error('Error updating workspace permissions:', error)
|
||||
return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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',
|
||||
BILLING_ENABLED: false,
|
||||
},
|
||||
isTruthy: (value: any) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
}))
|
||||
|
||||
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('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
BILLING_ENABLED: false,
|
||||
},
|
||||
isTruthy: (value: any) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
}))
|
||||
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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
332
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
332
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { render } from '@react-email/render'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
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) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitationId}?error=invalid-token`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
|
||||
export async function POST(
|
||||
_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()
|
||||
.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 resend pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ws = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const newToken = randomUUID()
|
||||
const newExpiresAt = new Date()
|
||||
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
|
||||
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() })
|
||||
.where(eq(workspaceInvitation.id, invitationId))
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}`
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
workspaceName: ws.name,
|
||||
inviterName: session.user.name || session.user.email || 'A user',
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await sendEmail({
|
||||
to: invitation.email,
|
||||
subject: `You've been invited to join "${ws.name}" on Sim`,
|
||||
html: emailHtml,
|
||||
from: getFromEmailAddress(),
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send invitation email. Please try again.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error resending workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,191 +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 { 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'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
permissions,
|
||||
type permissionTypeEnum,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
@@ -162,7 +163,7 @@ export async function POST(req: NextRequest) {
|
||||
and(
|
||||
eq(workspaceInvitation.workspaceId, workspaceId),
|
||||
eq(workspaceInvitation.email, email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
@@ -189,7 +190,7 @@ export async function POST(req: NextRequest) {
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
role,
|
||||
status: 'pending',
|
||||
status: 'pending' as WorkspaceInvitationStatus,
|
||||
token,
|
||||
permissions: permission,
|
||||
expiresAt,
|
||||
@@ -205,6 +206,7 @@ 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,
|
||||
})
|
||||
|
||||
@@ -220,17 +222,19 @@ 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'
|
||||
// Always use the client-side invite route with token parameter
|
||||
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
|
||||
// Use invitation ID in path, token in query parameter for security
|
||||
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}`
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
|
||||
@@ -161,6 +161,12 @@
|
||||
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;
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/chat/[subdomain]/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/[subdomain]/hooks'
|
||||
} from '@/app/chat/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './chat-client.css'
|
||||
import './chat.css'
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat-client'
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
|
||||
const { subdomain } = await params
|
||||
|
||||
@@ -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/[subdomain]/components/input/voice-input'
|
||||
import { VoiceInput } from '@/app/chat/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' className='border border-gray-200 bg-white text-gray-900'>
|
||||
<TooltipContent side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -3,10 +3,7 @@
|
||||
import { memo, type RefObject } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type ChatMessage,
|
||||
ClientChatMessage,
|
||||
} from '@/app/chat/[subdomain]/components/message/message'
|
||||
import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message'
|
||||
|
||||
interface ChatMessageContainerProps {
|
||||
messages: ChatMessage[]
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import MarkdownRenderer from './components/markdown-renderer'
|
||||
|
||||
@@ -80,10 +79,8 @@ export const ClientChatMessage = memo(
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='flex items-center gap-1.5 px-2 py-1'
|
||||
<button
|
||||
className='text-muted-foreground transition-colors hover:bg-muted'
|
||||
onClick={() => {
|
||||
const contentToCopy =
|
||||
typeof cleanTextContent === 'string'
|
||||
@@ -95,15 +92,11 @@ export const ClientChatMessage = memo(
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||
</>
|
||||
<Check className='h-3 w-3' strokeWidth={2} />
|
||||
) : (
|
||||
<>
|
||||
<Copy className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
</>
|
||||
<Copy className='h-3 w-3' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' align='center' sideOffset={5}>
|
||||
{isCopied ? 'Copied!' : 'Copy to clipboard'}
|
||||
@@ -5,7 +5,7 @@ import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ParticlesVisualization } from '@/app/chat/[subdomain]/components/voice-interface/components/particles'
|
||||
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user