Compare commits

...

62 Commits

Author SHA1 Message Date
Vikhyath Mondreti
784992f347 v0.3.50: debounce moved server side, hasWorkflowChanged fixes, advanced mode/serializer fix, jira fix, billing notifs 2025-09-08 11:53:44 -07:00
Waleed
5218dd41b9 fix(notifications): increase precision on billing calculations (#1283)
* update infra and remove railway

* fix(notifications): increase precision on billing calculations

* Revert "update infra and remove railway"

This reverts commit d17603e844.

* cleanup
2025-09-08 10:46:23 -07:00
Waleed
07e70409c7 feat(notifications): added notifications for usage thresholds, overages, and welcome emails (#1266)
* feat(notifications): added notifications for usage thresholds, overages, and welcome emails

* cleanup

* updated logo, ack PR comments

* ran migrations
2025-09-08 09:47:16 -07:00
Adam Gough
07ba17422b Fix(jira): reading multiple issues and write
fixed the read and write tools in jira
2025-09-06 20:48:49 -07:00
Waleed
d45324bb83 fix(sidebar): draggable cursor on sidebar when switching workflows (#1276) 2025-09-06 19:52:23 -07:00
Vikhyath Mondreti
ced64129da fix(subblock-param-mapping): consolidate resolution of advanced / basic mode params using canonicalParamId (#1274)
* fix(serializer): block's params mapper not running first

* fix

* fix

* revert

* add canonicalParamId

* fix

* fix tests

* fix discord

* fix condition checking

* edit condition check

* fix

* fix subblock config check

* fix

* add logging

* add more logs

* fix

* fix

* attempt

* fix discord

* remove unused discord code

* mark as required correctly
2025-09-06 17:33:49 -07:00
Vikhyath Mondreti
1e14743391 fix(sockets): move debounce to server side (#1265)
* fix(sockets): move debounce to server side

* remove comments / unused onBlur
2025-09-06 12:49:35 -07:00
Waleed
a0bb754c8c 0.3.49: readme updates, router block and variables improvements 2025-09-05 14:58:39 -07:00
Waleed
851031239d fix(variables): add back ability to reference root block like <start> (#1262) 2025-09-05 14:45:26 -07:00
Waleed
3811b509ef fix(router): change router block content to prompt (#1261)
* fix(router): remove prompt from router content

* fixed router
2025-09-05 13:39:04 -07:00
Vikhyath Mondreti
abb835d22d fix(schedule-self-host): remove incorrect migration (#1260)
* fix(schedule-self-host): remove incorrect migration

* delete unintentional file
2025-09-05 11:52:39 -07:00
Vikhyath Mondreti
f2a046ff24 improvement(docs): readme.md to mention .env setup for copilot setup 2025-09-05 11:01:54 -07:00
Vikhyath Mondreti
bd6d4a91a3 0.3.48: revert trigger dev bypass for enterprise users 2025-09-04 23:57:22 -07:00
Vikhyath Mondreti
21beca8fd5 fix(cleanup): cleanup unused vars + webhook typo (#1259) 2025-09-04 23:52:31 -07:00
Vikhyath Mondreti
0a86eda853 Revert "feat(enterprise-plan-webhooks): skip webhook queue for enterprise plan users (#1250)" (#1257)
This reverts commit 37dcde2afc.
2025-09-04 23:37:19 -07:00
Waleed
60a061e38a v0.3.47: race condition fixes, store rehydration consolidation, other bugs 2025-09-04 22:36:42 -07:00
Waleed
ab71fcfc49 feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256) 2025-09-04 22:15:27 -07:00
Vikhyath Mondreti
864622c1dc fix(ratelimits): enterprise and team checks should be pooled limit (#1255)
* fix(ratelimits): enterprise and team checks should be pooled limit"

* fix

* fix dynamic imports

* fix tests"
;
2025-09-04 21:44:56 -07:00
Waleed
8668622d66 feat(duplicate): duplicate variables when duplicating a workflow (#1254)
* feat(duplicate): duplicate variables when duplicating a workflow

* better typing
2025-09-04 21:20:30 -07:00
Waleed
53dd277cfe fix(cost): restored cost reporting for agent block in console entry (#1253) 2025-09-04 21:12:15 -07:00
Vikhyath Mondreti
0e8e8c7a47 fix(sidebar): order by created at (#1251) 2025-09-04 20:23:00 -07:00
Vikhyath Mondreti
47da5eb6e8 fix(rehydration): consolidate store rehydration code (#1249)
* fix(rehydration): consolidate store rehydration code

* fix stale closure
2025-09-04 20:00:51 -07:00
Vikhyath Mondreti
37dcde2afc feat(enterprise-plan-webhooks): skip webhook queue for enterprise plan users (#1250)
* feat(enterprise-plan-webhooks): skip webhook queue for enterprise plan users

* reuse subscription record instead of making extra db call
2025-09-04 20:00:24 -07:00
Vikhyath Mondreti
e31627c7c2 fix(sidebar): re-ordering based on last edit is confusing (#1248) 2025-09-04 18:30:59 -07:00
Vikhyath Mondreti
57c98d86ba fix(race-condition-workflow-switching): another race condition between registry and workflow stores (#1247)
* fix(race-condition-workflow-switching): another race condition between regitry and workflow stores"

* fix initial load race cond + cleanup

* fix initial load issue + simplify
2025-09-04 18:02:00 -07:00
Vikhyath Mondreti
0f7dfe084a fix(hydration): duplicate overlay after idle + subblocks race condition (#1246)
* fix(hydration): duplicate overlay after idle + subblocks race condition

* remove random timeout

* re-use correct helper

* remove redundant check

* add check

* remove third init func
2025-09-04 16:18:35 -07:00
Siddharth Ganesan
afc1632830 Merge pull request #1245 from simstudioai/fix/copilot-billing
improvement(copilot): billing multiplier adjustments
2025-09-04 12:05:17 -07:00
Siddharth Ganesan
56eee2c2d2 Waring 2025-09-04 11:37:06 -07:00
Siddharth Ganesan
fc558a8eef Lint + tests 2025-09-04 11:35:03 -07:00
Siddharth Ganesan
c68cadfb84 Docs 2025-09-04 11:27:54 -07:00
Siddharth Ganesan
95d93a2532 change 2025-09-04 11:23:36 -07:00
Siddharth Ganesan
59b2023124 Lint 2025-09-04 11:19:41 -07:00
Siddharth Ganesan
a672f17136 Add input/output multipliers 2025-09-04 11:19:00 -07:00
Waleed
1de59668e4 fix(whitelabel): move redirects (build-time) for whitelabeling to middlware (runtime) (#1236) 2025-09-03 16:36:47 -07:00
Waleed
26243b99e8 fix(code-subblock): added validation to not parse non-variables as variables in the code subblock (#1240)
* fix(code-subblock): added validation to not parse non-variables as variables in the code subblock

* fix wand prompt bar styling

* fix error message for available connected blocks to only show connected available blocks, not block ID's

* ui
2025-09-03 16:09:02 -07:00
Siddharth Ganesan
fce1423d05 v0.3.46: fix copilot stats updates
v0.3.46: fix copilot stats updates
2025-09-03 13:26:00 -07:00
Siddharth Ganesan
3656d3d7ad Updates (#1237) 2025-09-03 13:19:34 -07:00
Waleed
581929bc01 v0.3.45: fixes for organization invites, custom tool execution 2025-09-03 08:31:56 -07:00
Waleed
11d8188415 fix(rce): always use VM over RCE for custom tools (#1233) 2025-09-03 08:16:50 -07:00
Waleed
36c98d18e9 fix(team): fix organization invitation URL for teams (#1232) 2025-09-03 08:05:38 -07:00
Waleed
0cf87e650d v0.3.44: removing unused routes, whitelabeling terms & policy URLs, e2b remote code execution, copilot improvements 2025-09-02 21:29:55 -07:00
Waleed
baef8d77f9 fix(styling): fix styling inconsistencies in dark mode, fix invites fetching to show active members (#1229)
* fix(styling): fix unreadble text in dark mode

* fix styling inconsistencies in kb

* refetch permissions on invite modal open

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-09-02 21:17:15 -07:00
Vikhyath Mondreti
b74ab46820 fix(e2b-env-var): use isTruthy and getEnv (#1228) 2025-09-02 20:03:43 -07:00
Waleed
533b4c53e0 feat(tools): add MongoDB (#1225)
* added mongo, haven't tested

* fixed bugs, refined prompts, added billing for wand if billing enabled

* add docs

* ack PR comments
2025-09-02 18:55:45 -07:00
Siddharth Ganesan
c2d668c3eb feat(copilot): stats tracking (#1227)
* Add copilot stats table schema

* Move db to agent

* Lint

* Fix tests
2025-09-02 18:17:50 -07:00
Vikhyath Mondreti
1a5d5ddffa feat(e2b-execution): add remote code execution to support Python + Imports (#1226)
* feat(e2b-execution): add remote code execution via e2b

* ux improvements

* fix streaming

* progress

* fix tooltip text

* make supported languages an enum

* fix error handling

* fix tests
2025-09-02 18:15:29 -07:00
Waleed
9de0d91f9a feat(llms): added additional params to llm-based blocks for alternative models (#1223)
* feat(llms): added additional params to llm-based blocks for alternative models

* add hidden temp param to other LLM-based blocks
2025-09-02 13:29:03 -07:00
Waleed
3db73ff721 fix(whitelabel): make terms and privacy URL envvars available at build time (#1222) 2025-09-02 12:54:30 -07:00
Vikhyath Mondreti
9ffb48ee02 make 79th migration idempotent 2025-09-02 10:48:22 -07:00
Vikhyath Mondreti
1f2a317ac2 fix if not exists check 2025-09-02 10:39:53 -07:00
Vikhyath Mondreti
a618d289d8 add if not exists check 2025-09-02 10:38:33 -07:00
Vikhyath Mondreti
461d7b2342 Merge branch 'staging' of github.com:simstudioai/sim into staging 2025-09-02 10:27:08 -07:00
Vikhyath Mondreti
4273161c0f fix 80th migration 2025-09-02 10:26:57 -07:00
Waleed
54d42b33eb fix(wand): remove duplicate transfer encoding header meant to be set by nginx proxy (#1221) 2025-09-02 09:15:25 -07:00
Waleed
2c2c32c64b improvement(hygiene): refactored routes to be more restful, reduced code surface area and removed redundant code (#1217)
* improvement(invitations): consolidate invite-error and invite pages, made API endpoints more restful and reduced code surface area for invitations by 50%

* refactored logs API routes

* refactor rate limit api route, consolidate usage check api endpoint

* refactored chat page and invitations page

* consolidate ollama and openrouter stores to just providers store

* removed unused route

* removed legacy envvar methods

* remove dead, legacy routes for invitations PUT and workflow SYNC

* improvement(copilot): improve context inputs and fix some bugs (#1216)

* Add logs v1

* Update

* Updates

* Updates

* Fixes

* Fix current workflow in context

* Fix mentions

* Error handling

* Fix chat loading

* Hide current workflow from context

* Run workflow fix

* Lint

* updated invitation log

* styling for invitation pages

---------

Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2025-09-01 21:22:23 -07:00
Waleed
65e861822c fix(ui): dark mode styling for switch, trigger modal UI, signup/login improvements with auto-submit for OTP (#1214)
* fix(ui): fix dark mode styling for switch, fix trigger modal UI

* auto-submit OTP when characters are entered

* trim leading and trailing whitespace from name on signup, throw more informative error messages on reset pass
2025-09-01 21:19:12 -07:00
Siddharth Ganesan
12135d2aa8 improvement(copilot): improve context inputs and fix some bugs (#1216)
* Add logs v1

* Update

* Updates

* Updates

* Fixes

* Fix current workflow in context

* Fix mentions

* Error handling

* Fix chat loading

* Hide current workflow from context

* Run workflow fix

* Lint
2025-09-01 16:51:58 -07:00
Waleed
f75c807580 improvement(performance): added new indexes for improved session performance (#1215) 2025-09-01 16:00:15 -07:00
Vikhyath Mondreti
9ea7ea79e9 feat(workspace-vars): add workspace scoped environment + fix cancellation of assoc. workspace invites if org invite cancelled (#1208)
* feat(env-vars): workspace scoped environment variables

* fix cascade delete or workspace invite if org invite with attached workspace invites are created

* remove redundant refetch

* feat(env-vars): workspace scoped environment variables

* fix redirect for invitation error, remove check for validated emails on workspace invitation accept

* styling improvements

* remove random migration code

* stronger typing, added helpers, parallelized envvar encryption

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-09-01 15:56:58 -07:00
Waleed
5bbb349d8a fix(build): add missing pdf-parse dep, add docker build in staging (#1213)
* fix(build): add missing pdf-parse dep

* add docker build (no push) in staging
2025-09-01 13:04:16 -07:00
Waleed
ea09fcecb7 fix(build): consolidate pdf parsing dependencies, remove extraneous html deps (#1212)
* fix(build): consolidate pdf parsing dependencies, remove extraneous html deps

* add types
2025-09-01 10:19:24 -07:00
Waleed
9ccb7600f9 fix(organizations): remove org calls when billing is disabled (#1211) 2025-09-01 09:48:58 -07:00
284 changed files with 46954 additions and 6316 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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. |

View File

@@ -33,6 +33,7 @@
"microsoft_planner",
"microsoft_teams",
"mistral_parse",
"mongodb",
"mysql",
"notion",
"onedrive",

View 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`

View File

@@ -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

View File

@@ -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)
)
})
})

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
})

View File

@@ -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)
}

View File

@@ -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',
}),
})
)

View File

@@ -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(),

View 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')
}
}

View File

@@ -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: '' }
}
}

View File

@@ -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 }
)
}
}

View File

@@ -553,22 +553,11 @@ function handleGenericBuffer(
*/
async function parseBufferAsPdf(buffer: Buffer) {
try {
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) {
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}`)
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -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`, {

View File

@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
const logger = createLogger('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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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,

View File

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

View File

@@ -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 })
}
}

View File

@@ -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]) => {

View File

@@ -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[]

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,
}

View 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()
}
}
}

View 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()
}
}
}

View 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()
}
}
}

View 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()
}
}
}

View 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()
}
}
}

View 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
}

View File

@@ -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 })
}
}

View File

@@ -7,11 +7,11 @@ import {
} from '@/lib/billing/core/organization'
import { createLogger } from '@/lib/logs/console/logger'
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) {

View File

@@ -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
)

View File

@@ -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 }

View File

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

View File

@@ -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
)

View File

@@ -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,
}
})

View File

@@ -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,
})

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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(),
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
}
}

View 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 }
)
}
}

View File

@@ -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' })
})
})

View File

@@ -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 })
}
}

View File

@@ -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' })
})
})
})

View 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 })
}
}

View File

@@ -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'
)
)
}
}

View File

@@ -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 })
}
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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')

View File

@@ -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 (

View File

@@ -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

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { Send, Square } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { VoiceInput } from '@/app/chat/[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>

View File

@@ -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[]

View File

@@ -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'}

View File

@@ -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')

View File

@@ -2,7 +2,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatMessage } from '@/app/chat/[subdomain]/components/message/message'
import type { ChatMessage } from '@/app/chat/components/message/message'
// No longer need complex output extraction - backend handles this
import type { ExecutionResult } from '@/executor/types'

View File

@@ -1,13 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertCircle, CheckCircle2, Mail, UserPlus, Users2 } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { client, useSession } from '@/lib/auth-client'
import { useBrandConfig } from '@/lib/branding/branding'
import { createLogger } from '@/lib/logs/console/logger'
import { getErrorMessage } from '@/app/invite/[id]/utils'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('InviteById')
export default function Invite() {
const router = useRouter()
@@ -15,7 +15,6 @@ export default function Invite() {
const inviteId = params.id as string
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const brandConfig = useBrandConfig()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -25,12 +24,18 @@ export default function Invite() {
const [token, setToken] = useState<string | null>(null)
const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace')
// Check if this is a new user vs. existing user and get token from query
useEffect(() => {
const errorReason = searchParams.get('error')
if (errorReason) {
setError(getErrorMessage(errorReason))
setIsLoading(false)
return
}
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
// Get token from URL or use inviteId as token
const tokenFromQuery = searchParams.get('token')
const effectiveToken = tokenFromQuery || inviteId
@@ -40,20 +45,16 @@ export default function Invite() {
}
}, [searchParams, inviteId])
// Auto-fetch invitation details when logged in
useEffect(() => {
if (!session?.user || !token) return
async function fetchInvitationDetails() {
setIsLoading(true)
try {
// First try to fetch workspace invitation details
const workspaceInviteResponse = await fetch(
`/api/workspaces/invitations/details?token=${token}`,
{
method: 'GET',
}
)
// Fetch invitation details using the invitation ID from the URL path
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
method: 'GET',
})
if (workspaceInviteResponse.ok) {
const data = await workspaceInviteResponse.json()
@@ -67,7 +68,6 @@ export default function Invite() {
return
}
// If workspace invitation not found, try organization invitation
try {
const { data } = await client.organization.getInvitation({
query: { id: inviteId },
@@ -81,7 +81,6 @@ export default function Invite() {
name: data.organizationName || 'an organization',
})
// Get organization details
if (data.organizationId) {
const orgResponse = await client.organization.getFullOrganization({
query: { organizationId: data.organizationId },
@@ -98,11 +97,10 @@ export default function Invite() {
throw new Error('Invitation not found or has expired')
}
} catch (_err) {
// If neither workspace nor organization invitation is found
throw new Error('Invitation not found or has expired')
}
} catch (err: any) {
console.error('Error fetching invitation:', err)
logger.error('Error fetching invitation:', err)
setError(err.message || 'Failed to load invitation details')
} finally {
setIsLoading(false)
@@ -112,36 +110,19 @@ export default function Invite() {
fetchInvitationDetails()
}, [session?.user, inviteId, token])
// Handle invitation acceptance
const handleAcceptInvitation = async () => {
if (!session?.user) return
setIsAccepting(true)
try {
if (invitationType === 'workspace') {
// For workspace invites, call the API route with token
const response = await fetch(
`/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}`
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to accept invitation')
}
setAccepted(true)
// Redirect to workspace after a brief delay
setTimeout(() => {
router.push('/workspace')
}, 2000)
} else {
// For organization invites, use the client API
if (invitationType === 'workspace') {
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
} else {
try {
const response = await client.organization.acceptInvitation({
invitationId: inviteId,
})
// Set the active organization to the one just joined
const orgId =
response.data?.invitation.organizationId || invitationDetails?.data?.organizationId
@@ -153,294 +134,147 @@ export default function Invite() {
setAccepted(true)
// Redirect to workspace after a brief delay
setTimeout(() => {
router.push('/workspace')
}, 2000)
} catch (err: any) {
logger.error('Error accepting invitation:', err)
setError(err.message || 'Failed to accept invitation')
} finally {
setIsAccepting(false)
}
} catch (err: any) {
console.error('Error accepting invitation:', err)
setError(err.message || 'Failed to accept invitation')
} finally {
setIsAccepting(false)
}
}
// Prepare the callback URL - this ensures after login, user returns to invite page
const getCallbackUrl = () => {
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
}
// Show login/signup prompt if not logged in
if (!session?.user && !isPending) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
<UserPlus className='h-8 w-8 text-blue-500 dark:text-blue-400' />
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
You've been invited!
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
{isNewUser
<InviteLayout>
<InviteStatusCard
type='login'
title="You've been invited!"
description={
isNewUser
? 'Create an account to join this workspace on Sim'
: 'Sign in to your account to accept this invitation'}
</p>
<div className='flex w-full flex-col gap-3'>
{isNewUser ? (
<>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
Create an account
</Button>
<Button
variant='outline'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
I already have an account
</Button>
</>
) : (
<>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
Sign in
</Button>
<Button
variant='outline'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
onClick={() =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`)
}
>
Create an account
</Button>
</>
)}
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
: 'Sign in to your account to accept this invitation'
}
icon='userPlus'
actions={[
...(isNewUser
? [
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'I already have an account',
onClick: () =>
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'outline' as const,
},
]
: [
{
label: 'Sign in',
onClick: () =>
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
variant: 'outline' as const,
},
]),
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
]}
/>
</InviteLayout>
)
}
// Show loading state
if (isLoading || isPending) {
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<LoadingAgent size='lg' />
<p className='mt-4 text-gray-400 text-sm'>Loading invitation...</p>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
<InviteLayout>
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
</InviteLayout>
)
}
// Show error state
if (error) {
const errorReason = searchParams.get('error')
const isExpiredError = errorReason === 'expired'
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
<AlertCircle className='h-8 w-8 text-red-500 dark:text-red-400' />
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
Invitation Error
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>{error}</p>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation Error'
description={error}
icon='error'
isExpiredError={isExpiredError}
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
]}
/>
</InviteLayout>
)
}
// Show success state
if (accepted) {
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-green-50 p-3 dark:bg-green-950/20'>
<CheckCircle2 className='h-8 w-8 text-green-500 dark:text-green-400' />
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Welcome!</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting
to your workspace...
</p>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
<InviteLayout>
<InviteStatusCard
type='success'
title='Welcome!'
description={`You have successfully joined ${invitationDetails?.name || 'the workspace'}. Redirecting to your workspace...`}
icon='success'
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
]}
/>
</InviteLayout>
)
}
// Show invitation details
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src='/logo/b&w/medium.png'
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
{invitationType === 'organization' ? (
<Users2 className='h-8 w-8 text-blue-500 dark:text-blue-400' />
) : (
<Mail className='h-8 w-8 text-blue-500 dark:text-blue-400' />
)}
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
{invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'}
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
You've been invited to join{' '}
<span className='font-medium text-black dark:text-white'>
{invitationDetails?.name || `a ${invitationType}`}
</span>
. Click accept below to join.
</p>
<div className='flex w-full flex-col gap-3'>
<Button
onClick={handleAcceptInvitation}
disabled={isAccepting}
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
>
{isAccepting ? (
<>
<LoadingAgent size='sm' />
Accepting...
</>
) : (
'Accept Invitation'
)}
</Button>
<Button
variant='ghost'
className='w-full text-gray-600 hover:bg-gray-200 hover:text-black dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
<InviteLayout>
<InviteStatusCard
type='invitation'
title={
invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'
}
description={`You've been invited to join ${invitationDetails?.name || `a ${invitationType}`}. Click accept below to join.`}
icon={invitationType === 'organization' ? 'users' : 'mail'}
actions={[
{
label: 'Accept Invitation',
onClick: handleAcceptInvitation,
disabled: isAccepting,
loading: isAccepting,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost',
},
]}
/>
</InviteLayout>
)
}

View File

@@ -0,0 +1,28 @@
export function getErrorMessage(reason: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
}
}

View File

@@ -0,0 +1,2 @@
export { InviteLayout } from './layout'
export { InviteStatusCard } from './status-card'

View File

@@ -0,0 +1,56 @@
'use client'
import Image from 'next/image'
import { useBrandConfig } from '@/lib/branding/branding'
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
interface InviteLayoutProps {
children: React.ReactNode
}
export function InviteLayout({ children }: InviteLayoutProps) {
const brandConfig = useBrandConfig()
return (
<main className='dark relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
{/* Background pattern */}
<GridPattern
x={-5}
y={-5}
className='absolute inset-0 z-0 stroke-[#ababab]/5'
width={90}
height={90}
aria-hidden='true'
/>
{/* Content */}
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
<div className='w-full max-w-md'>
<div className='mb-8 text-center'>
<Image
src={brandConfig.logoUrl || '/logo/primary/text/medium.png'}
alt='Sim Logo'
width={140}
height={42}
priority
className='mx-auto'
/>
</div>
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
{children}
</div>
<div className='mt-6 text-center text-neutral-500/80 text-xs leading-relaxed'>
Need help?{' '}
<a
href={`mailto:${brandConfig.supportEmail}`}
className='text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Contact support
</a>
</div>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { LoadingAgent } from '@/components/ui/loading-agent'
interface InviteStatusCardProps {
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
title: string
description: string | React.ReactNode
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success'
actions?: Array<{
label: string
onClick: () => void
variant?: 'default' | 'outline' | 'ghost'
disabled?: boolean
loading?: boolean
}>
isExpiredError?: boolean
}
const iconMap = {
userPlus: UserPlus,
mail: Mail,
users: Users2,
error: ShieldX,
success: CheckCircle2,
}
const iconColorMap = {
userPlus: 'text-[#701ffc]',
mail: 'text-[#701ffc]',
users: 'text-[#701ffc]',
error: 'text-red-500 dark:text-red-400',
success: 'text-green-500 dark:text-green-400',
}
const iconBgMap = {
userPlus: 'bg-[#701ffc]/10',
mail: 'bg-[#701ffc]/10',
users: 'bg-[#701ffc]/10',
error: 'bg-red-50 dark:bg-red-950/20',
success: 'bg-green-50 dark:bg-green-950/20',
}
export function InviteStatusCard({
type,
title,
description,
icon,
actions = [],
isExpiredError = false,
}: InviteStatusCardProps) {
const router = useRouter()
if (type === 'loading') {
return (
<div className='flex w-full max-w-md flex-col items-center'>
<LoadingAgent size='lg' />
<p className='mt-4 text-muted-foreground text-sm'>{description}</p>
</div>
)
}
const IconComponent = icon ? iconMap[icon] : null
const iconColor = icon ? iconColorMap[icon] : ''
const iconBg = icon ? iconBgMap[icon] : ''
return (
<div className='flex w-full max-w-md flex-col items-center text-center'>
{IconComponent && (
<div className={`mb-6 rounded-full p-3 ${iconBg}`}>
<IconComponent className={`h-8 w-8 ${iconColor}`} />
</div>
)}
<h1 className='mb-2 font-semibold text-[32px] text-white tracking-tight'>{title}</h1>
<p className='mb-6 text-neutral-400 text-sm leading-relaxed'>{description}</p>
<div className='flex w-full flex-col gap-3'>
{isExpiredError && (
<Button
variant='outline'
className='h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
onClick={() => router.push('/')}
>
<RotateCcw className='mr-2 h-4 w-4' />
Request New Invitation
</Button>
)}
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'default'}
className={
(action.variant || 'default') === 'default'
? 'h-11 w-full bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
: action.variant === 'outline'
? 'h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
: 'h-11 w-full text-muted-foreground hover:bg-secondary hover:text-foreground'
}
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{action.loading ? (
<>
<LoadingAgent size='sm' />
{action.label}...
</>
) : (
action.label
)}
</Button>
))}
</div>
</div>
)
}

View File

@@ -1,136 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Mail, RotateCcw, ShieldX } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding'
function getErrorMessage(reason: string, details?: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return details
? details
: 'This invitation was sent to a different email address than the one you are logged in with.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'email-not-verified':
return details
? details
: 'You must verify your email address before accepting invitations. Please check your email for a verification link.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
}
}
export default function InviteError() {
const searchParams = useSearchParams()
const reason = searchParams?.get('reason') || 'unknown'
const details = searchParams?.get('details')
const [errorMessage, setErrorMessage] = useState('')
const brandConfig = useBrandConfig()
useEffect(() => {
// Only set the error message on the client side
setErrorMessage(getErrorMessage(reason, details || undefined))
}, [reason, details])
// Provide a fallback message for SSR
const displayMessage = errorMessage || 'Loading error details...'
const isEmailVerificationError = reason === 'email-not-verified'
const isExpiredError = reason === 'expired'
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
{/* Logo */}
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
<ShieldX className='h-8 w-8 text-red-500 dark:text-red-400' />
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Invitation Error</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
{displayMessage}
</p>
<div className='flex w-full flex-col gap-3'>
{isEmailVerificationError && (
<Button
variant='default'
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
asChild
>
<Link href='/verify'>
<Mail className='mr-2 h-4 w-4' />
Verify Email
</Link>
</Button>
)}
{isExpiredError && (
<Button
variant='outline'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
asChild
>
<Link href='/'>
<RotateCcw className='mr-2 h-4 w-4' />
Request New Invitation
</Link>
</Button>
)}
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
asChild
>
<Link href='/'>Return to Home</Link>
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}

View File

@@ -1,7 +0,0 @@
import InviteError from '@/app/invite/invite-error/invite-error'
export const dynamic = 'force-dynamic'
export default function InviteErrorPage() {
return <InviteError />
}

View File

@@ -1,401 +1,3 @@
'use client'
import Unsubscribe from './unsubscribe'
import { Suspense, useEffect, useState } from 'react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { useBrandConfig } from '@/lib/branding/branding'
interface UnsubscribeData {
success: boolean
email: string
token: string
emailType: string
isTransactional: boolean
currentPreferences: {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
}
function UnsubscribeContent() {
const searchParams = useSearchParams()
const [loading, setLoading] = useState(true)
const [data, setData] = useState<UnsubscribeData | null>(null)
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email')
const token = searchParams.get('token')
useEffect(() => {
if (!email || !token) {
setError('Missing email or token in URL')
setLoading(false)
return
}
// Validate the unsubscribe link
fetch(
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
.then((res) => res.json())
.then((data) => {
if (data.success) {
setData(data)
} else {
setError(data.error || 'Invalid unsubscribe link')
}
})
.catch(() => {
setError('Failed to validate unsubscribe link')
})
.finally(() => {
setLoading(false)
})
}, [email, token])
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
if (!email || !token) return
setProcessing(true)
try {
const response = await fetch('/api/users/me/settings/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
token,
type,
}),
})
const result = await response.json()
if (result.success) {
setUnsubscribed(true)
// Update the data to reflect the change
if (data) {
// Type-safe property construction with validation
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
}
}
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch (error) {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
}
}
if (loading) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
// Handle transactional emails
if (data?.isTransactional) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
if (unsubscribed) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{processing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
export default function UnsubscribePage() {
return (
<Suspense
fallback={
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
}
>
<UnsubscribeContent />
</Suspense>
)
}
export default Unsubscribe

View File

@@ -0,0 +1,401 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { useBrandConfig } from '@/lib/branding/branding'
interface UnsubscribeData {
success: boolean
email: string
token: string
emailType: string
isTransactional: boolean
currentPreferences: {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
}
function UnsubscribeContent() {
const searchParams = useSearchParams()
const [loading, setLoading] = useState(true)
const [data, setData] = useState<UnsubscribeData | null>(null)
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email')
const token = searchParams.get('token')
useEffect(() => {
if (!email || !token) {
setError('Missing email or token in URL')
setLoading(false)
return
}
// Validate the unsubscribe link
fetch(
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
.then((res) => res.json())
.then((data) => {
if (data.success) {
setData(data)
} else {
setError(data.error || 'Invalid unsubscribe link')
}
})
.catch(() => {
setError('Failed to validate unsubscribe link')
})
.finally(() => {
setLoading(false)
})
}, [email, token])
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
if (!email || !token) return
setProcessing(true)
try {
const response = await fetch('/api/users/me/settings/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
token,
type,
}),
})
const result = await response.json()
if (result.success) {
setUnsubscribed(true)
// Update the data to reflect the change
if (data) {
// Type-safe property construction with validation
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
}
}
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch (error) {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
}
}
if (loading) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
// Handle transactional emails
if (data?.isTransactional) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
if (unsubscribed) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{processing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
export default function Unsubscribe() {
return (
<Suspense
fallback={
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
}
>
<UnsubscribeContent />
</Suspense>
)
}

View File

@@ -26,7 +26,7 @@ export default function Workflow() {
const fetchWorkflows = async () => {
try {
setLoading(true)
const response = await fetch('/api/workflows/sync')
const response = await fetch('/api/workflows')
if (response.ok) {
const { data } = await response.json()
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({

View File

@@ -502,7 +502,7 @@ export function FrozenCanvas({
setLoading(true)
setError(null)
const response = await fetch(`/api/logs/${executionId}/frozen-canvas`)
const response = await fetch(`/api/logs/execution/${executionId}`)
if (!response.ok) {
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
}

View File

@@ -161,7 +161,7 @@ export default function Logs() {
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
@@ -216,7 +216,7 @@ export default function Logs() {
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
@@ -274,7 +274,7 @@ export default function Logs() {
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data

View File

@@ -19,6 +19,7 @@ interface WorkspacePermissionsContextType {
permissionsLoading: boolean
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetchPermissions: () => Promise<void>
// Computed user permissions (connection-aware)
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
@@ -32,6 +33,7 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
refetchPermissions: async () => {},
userPermissions: {
canRead: false,
canEdit: false,
@@ -74,6 +76,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
refetch: refetchPermissions,
} = useWorkspacePermissions(workspaceId)
// Get base user permissions from workspace permissions
@@ -113,10 +116,18 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
setOfflineMode: setIsOfflineMode,
}),
[workspacePermissions, permissionsLoading, permissionsError, updatePermissions, userPermissions]
[
workspacePermissions,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
]
)
return (

View File

@@ -53,7 +53,7 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-foreground text-sm'
>
{subdomainPart}
</a>
@@ -67,7 +67,7 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
className='text-foreground hover:underline'
>
this URL
</a>

View File

@@ -79,7 +79,7 @@ export function ExampleCommand({
case 'rate-limits': {
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: ${apiKey}" \\
${baseUrlForRateLimit}/api/users/rate-limit`
${baseUrlForRateLimit}/api/users/me/rate-limit`
}
default:
@@ -119,7 +119,7 @@ export function ExampleCommand({
case 'rate-limits': {
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: SIM_API_KEY" \\
${baseUrlForRateLimit}/api/users/rate-limit`
${baseUrlForRateLimit}/api/users/me/rate-limit`
}
default:

View File

@@ -45,6 +45,7 @@ import {
useKeyboardShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useFolderStore } from '@/stores/folders/store'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -258,17 +259,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
// Get current store state for change detection
const currentBlocks = useWorkflowStore((state) => state.blocks)
const currentEdges = useWorkflowStore((state) => state.edges)
const subBlockValues = useSubBlockStore((state) =>
activeWorkflowId ? state.workflowValues[activeWorkflowId] : null
)
useEffect(() => {
// Avoid off-by-one false positives: wait until operation queue is idle
const { operations, isProcessing } = useOperationQueueStore.getState()
const hasPendingOps =
isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing')
if (!activeWorkflowId || !deployedState) {
setChangeDetected(false)
return
}
if (isLoadingDeployedState) {
if (isLoadingDeployedState || hasPendingOps) {
return
}
@@ -291,7 +298,16 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
checkForChanges()
}, [activeWorkflowId, deployedState, currentBlocks, subBlockValues, isLoadingDeployedState])
}, [
activeWorkflowId,
deployedState,
currentBlocks,
currentEdges,
subBlockValues,
isLoadingDeployedState,
useOperationQueueStore.getState().isProcessing,
useOperationQueueStore.getState().operations.length,
])
useEffect(() => {
if (session?.user?.id && !isRegistryLoading) {
@@ -321,7 +337,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
try {
// Primary: call server-side usage check to mirror backend enforcement
const res = await fetch('/api/usage/check', { cache: 'no-store' })
const res = await fetch('/api/usage?context=user', { cache: 'no-store' })
if (res.ok) {
const payload = await res.json()
const usage = payload?.data
@@ -1101,21 +1117,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
* Get workflows in the exact order they appear in the sidebar
*/
const getSidebarOrderedWorkflows = () => {
// Get and sort regular workflows by last modified (newest first)
// Get and sort regular workflows by creation date (newest first) for stable ordering
const regularWorkflows = Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.filter((workflow) => workflow.marketplaceData?.status !== 'temp')
.sort((a, b) => {
const dateA =
a.lastModified instanceof Date
? a.lastModified.getTime()
: new Date(a.lastModified).getTime()
const dateB =
b.lastModified instanceof Date
? b.lastModified.getTime()
: new Date(b.lastModified).getTime()
return dateB - dateA
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
// Group workflows by folder
const workflowsByFolder = regularWorkflows.reduce(

View File

@@ -3,7 +3,9 @@
import { type FC, memo, useEffect, useMemo, useState } from 'react'
import {
Blocks,
BookOpen,
Bot,
Box,
Check,
Clipboard,
Info,
@@ -11,6 +13,7 @@ import {
Loader2,
RotateCcw,
Shapes,
SquareChevronRight,
ThumbsDown,
ThumbsUp,
Workflow,
@@ -389,7 +392,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const fromBlock = Array.isArray((block as any)?.contexts)
? ((block as any).contexts as any[])
: []
const allContexts = direct.length > 0 ? direct : fromBlock
const allContexts = (direct.length > 0 ? direct : fromBlock).filter(
(c: any) => c?.kind !== 'current_workflow'
)
const MAX_VISIBLE = 4
const visible = showAllContexts
? allContexts
@@ -404,14 +409,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
>
{ctx?.kind === 'past_chat' ? (
<Bot className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'workflow' ? (
) : ctx?.kind === 'workflow' || ctx?.kind === 'current_workflow' ? (
<Workflow className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'blocks' ? (
<Blocks className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'workflow_block' ? (
<Box className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'knowledge' ? (
<LibraryBig className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'templates' ? (
<Shapes className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'docs' ? (
<BookOpen className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'logs' ? (
<SquareChevronRight className='h-3 w-3 text-muted-foreground' />
) : (
<Info className='h-3 w-3 text-muted-foreground' />
)}
@@ -500,7 +511,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
const labels = contexts.map((c) => c?.label).filter(Boolean) as string[]
const labels = contexts
.filter((c) => c?.kind !== 'current_workflow')
.map((c) => c?.label)
.filter(Boolean) as string[]
if (!labels.length) return <WordWrap text={text} />
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

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