Compare commits

...

37 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
126 changed files with 14690 additions and 2484 deletions

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

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

@@ -226,6 +226,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
chatId: 'chat-123',
}),
})
)
@@ -289,6 +290,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
chatId: 'chat-123',
}),
})
)
@@ -341,6 +343,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
depth: 0,
chatId: 'chat-123',
}),
})
)
@@ -430,6 +433,7 @@ describe('Copilot Chat API Route', () => {
mode: 'ask',
messageId: 'mock-uuid-1234-5678',
depth: 0,
chatId: 'chat-123',
}),
})
)

View File

@@ -378,6 +378,7 @@ export async function POST(req: NextRequest) {
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(agentContexts.length > 0 && { context: agentContexts }),
...(actualChatId ? { chatId: actualChatId } : {}),
}
try {

View File

@@ -12,23 +12,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const BodySchema = z
.object({
// Do NOT send id; messageId is the unique correlator
userId: z.string().optional(),
chatId: z.string().uuid().optional(),
messageId: z.string().optional(),
depth: z.number().int().nullable().optional(),
maxEnabled: z.boolean().nullable().optional(),
createdAt: z.union([z.string().datetime(), z.date()]).optional(),
diffCreated: z.boolean().nullable().optional(),
diffAccepted: z.boolean().nullable().optional(),
duration: z.number().int().nullable().optional(),
inputTokens: z.number().int().nullable().optional(),
outputTokens: z.number().int().nullable().optional(),
aborted: z.boolean().nullable().optional(),
})
.passthrough()
const BodySchema = z.object({
messageId: z.string(),
diffCreated: z.boolean(),
diffAccepted: z.boolean(),
})
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
@@ -43,15 +31,15 @@ export async function POST(req: NextRequest) {
if (!parsed.success) {
return createBadRequestResponse('Invalid request body for copilot stats')
}
const body = parsed.data as any
// Build outgoing payload for Sim Agent; do not include id
const { messageId, diffCreated, diffAccepted } = parsed.data as any
// Build outgoing payload for Sim Agent with only required fields
const payload: Record<string, any> = {
...body,
userId: body.userId || userId,
createdAt: body.createdAt || new Date().toISOString(),
messageId,
diffCreated,
diffAccepted,
}
payload.id = undefined
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/stats`, {
method: 'POST',

View File

@@ -4,6 +4,7 @@ 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'
@@ -18,11 +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 { 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
@@ -108,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
)

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

@@ -1,10 +1,11 @@
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')
@@ -33,31 +34,22 @@ export async function GET(request: NextRequest) {
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
| '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

@@ -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,7 @@ 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'
@@ -19,15 +20,10 @@ import {
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { subscription, userStats } from '@/db/schema'
import { 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')
@@ -374,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
)
@@ -505,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
)
@@ -580,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
)

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

@@ -64,7 +64,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
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()
@@ -378,6 +383,16 @@ describe('Workspace Invitation [invitationId] API Route', () => {
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' },
}))

View File

@@ -1,7 +1,11 @@
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'
@@ -48,6 +52,14 @@ export async function GET(
.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 })
}
@@ -234,3 +246,87 @@ export async function DELETE(
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

@@ -9,7 +9,7 @@ export function getErrorMessage(reason: string): string {
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 or contact the person who invited you.'
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':

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

@@ -393,11 +393,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const workspaceFiltered = items.filter(
(w: any) => w.workspaceId === workspaceId || !w.workspaceId
)
// Sort by last modified/updated (newest first), matching sidebar behavior
// Sort by creation date (newest first) for stable ordering, matching sidebar behavior
const sorted = [...workspaceFiltered].sort((a: any, b: any) => {
const ta = new Date(a.lastModified || a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.lastModified || b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA // Newest first for stable ordering
})
setWorkflows(
sorted.map((w: any) => ({

View File

@@ -81,15 +81,15 @@ export function WandPromptBar({
<div
ref={promptBarRef}
className={cn(
'-top-20 absolute right-0 left-0',
'rounded-xl border bg-background shadow-lg',
'-translate-y-3 absolute right-0 bottom-full left-0 gap-2',
'rounded-lg border bg-background shadow-lg',
'z-9999999 transition-all duration-150',
isExiting ? 'opacity-0' : 'opacity-100',
className
)}
>
<div className='flex items-center gap-2 p-2'>
<div className={cn('status-indicator ml-1', isStreaming && 'streaming')} />
<div className={cn('status-indicator ml-2 self-center', isStreaming && 'streaming')} />
<div className='relative flex-1'>
<Input
@@ -98,7 +98,7 @@ export function WandPromptBar({
placeholder={placeholder}
className={cn(
'rounded-xl border-0 text-foreground text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
isStreaming && 'text-primary',
isStreaming && 'text-foreground/70',
(isLoading || isStreaming) && 'loading-placeholder'
)}
onKeyDown={(e) => {
@@ -111,11 +111,6 @@ export function WandPromptBar({
disabled={isLoading || isStreaming}
autoFocus={!isStreaming}
/>
{isStreaming && (
<div className='pointer-events-none absolute inset-0 h-full w-full overflow-hidden'>
<div className='shimmer-effect' />
</div>
)}
</div>
<Button
@@ -141,14 +136,6 @@ export function WandPromptBar({
</div>
<style jsx global>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes smoke-pulse {
0%,
@@ -164,8 +151,8 @@ export function WandPromptBar({
.status-indicator {
position: relative;
width: 16px;
height: 16px;
width: 12px;
height: 12px;
border-radius: 50%;
overflow: hidden;
background-color: hsl(var(--muted-foreground) / 0.5);
@@ -183,36 +170,20 @@ export function WandPromptBar({
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.7) 0%,
hsl(var(--primary) / 0.2) 60%,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--primary) / 0.4) 60%,
transparent 80%
);
animation: smoke-pulse 1.8s ease-in-out infinite;
opacity: 0.9;
}
.shimmer-effect {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 2s infinite;
.dark .status-indicator.streaming::before {
background: #6b7280;
opacity: 0.9;
animation: smoke-pulse 1.8s ease-in-out infinite;
}
.dark .shimmer-effect {
background: linear-gradient(
90deg,
rgba(50, 50, 50, 0) 0%,
rgba(80, 80, 80, 0.4) 50%,
rgba(50, 50, 50, 0) 100%
);
}
`}</style>
</div>
)

View File

@@ -404,10 +404,8 @@ IMPORTANT FORMATTING RULES:
<div
className={cn(
'group relative min-h-[100px] rounded-md border border-input bg-background font-mono text-sm transition-colors',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2',
!isValidJson && 'border-destructive bg-destructive/10'
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
title={!isValidJson ? 'Invalid JSON' : undefined}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
@@ -419,7 +417,7 @@ IMPORTANT FORMATTING RULES:
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
disabled={isAiLoading || isAiStreaming}
aria-label='Generate code with AI'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
>
<Wand2 className='h-4 w-4' />
</Button>

View File

@@ -1,322 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { DiscordIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DiscordChannelSelector')
export interface DiscordChannelInfo {
id: string
name: string
type: number
}
interface DiscordChannelSelectorProps {
value: string
onChange: (value: string, channelInfo?: DiscordChannelInfo) => void
botToken: string
serverId: string
label?: string
disabled?: boolean
showPreview?: boolean
onChannelInfoChange?: (info: DiscordChannelInfo | null) => void
}
export function DiscordChannelSelector({
value,
onChange,
botToken,
serverId,
label = 'Select Discord channel',
disabled = false,
showPreview = true,
onChannelInfoChange,
}: DiscordChannelSelectorProps) {
const [open, setOpen] = useState(false)
const [channels, setChannels] = useState<DiscordChannelInfo[]>([])
const [selectedChannelId, setSelectedChannelId] = useState(value)
const [selectedChannel, setSelectedChannel] = useState<DiscordChannelInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Fetch channels from Discord API
const fetchChannels = useCallback(async () => {
if (!botToken || !serverId) {
setError(!botToken ? 'Bot token is required' : 'Server ID is required')
return
}
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/discord/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken, serverId }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord channels')
}
const data = await response.json()
setChannels(data.channels || [])
// If we have a selected channel ID, find the channel info
const currentSelectedId = selectedChannelId // Store in local variable
if (currentSelectedId) {
const channelInfo = data.channels?.find(
(channel: DiscordChannelInfo) => channel.id === currentSelectedId
)
if (channelInfo) {
setSelectedChannel(channelInfo)
onChannelInfoChange?.(channelInfo)
}
}
} catch (error) {
logger.error('Error fetching channels:', error)
setError((error as Error).message)
setChannels([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [botToken, serverId, selectedChannelId, onChannelInfoChange])
// Handle open change - only fetch channels when the dropdown is opened
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch channels when opening the dropdown and if we have valid token and server
if (isOpen && botToken && serverId && (!initialFetchDone || channels.length === 0)) {
fetchChannels()
}
}
// Fetch only the selected channel info when component mounts or when selectedChannelId changes
// This is more efficient than fetching all channels
const fetchSelectedChannelInfo = useCallback(async () => {
if (!botToken || !serverId || !selectedChannelId) return
setIsLoading(true)
setError(null)
try {
// Only fetch the specific channel by ID instead of all channels
const response = await fetch('/api/tools/discord/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
botToken,
serverId,
channelId: selectedChannelId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord channel')
}
const data = await response.json()
if (data.channel) {
setSelectedChannel(data.channel)
onChannelInfoChange?.(data.channel)
} else if (data.channels && data.channels.length > 0) {
const channelInfo = data.channels.find(
(channel: DiscordChannelInfo) => channel.id === selectedChannelId
)
if (channelInfo) {
setSelectedChannel(channelInfo)
onChannelInfoChange?.(channelInfo)
}
}
} catch (error) {
logger.error('Error fetching channel info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
}, [botToken, serverId, selectedChannelId, onChannelInfoChange])
// Fetch selected channel info when component mounts or dependencies change
useEffect(() => {
if (value && botToken && serverId && (!selectedChannel || selectedChannel.id !== value)) {
fetchSelectedChannelInfo()
}
}, [value, botToken, serverId, selectedChannel, fetchSelectedChannelInfo])
// Sync with external value
useEffect(() => {
if (value !== selectedChannelId) {
setSelectedChannelId(value)
// Find channel info for the new value
if (value && channels.length > 0) {
const channelInfo = channels.find((channel) => channel.id === value)
setSelectedChannel(channelInfo || null)
onChannelInfoChange?.(channelInfo || null)
} else if (value) {
// If we have a value but no channel info, we might need to fetch it
if (!selectedChannel || selectedChannel.id !== value) {
fetchSelectedChannelInfo()
}
} else {
setSelectedChannel(null)
onChannelInfoChange?.(null)
}
}
}, [
value,
channels,
selectedChannelId,
selectedChannel,
fetchSelectedChannelInfo,
onChannelInfoChange,
])
// Handle channel selection
const handleSelectChannel = (channel: DiscordChannelInfo) => {
setSelectedChannelId(channel.id)
setSelectedChannel(channel)
onChange(channel.id, channel)
onChannelInfoChange?.(channel)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedChannelId('')
setSelectedChannel(null)
onChange('', undefined)
onChannelInfoChange?.(null)
setError(null)
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !botToken || !serverId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedChannel ? (
<>
<span className='text-muted-foreground'>#</span>
<span className='truncate font-normal'>{selectedChannel.name}</span>
</>
) : (
<>
<DiscordIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search channels...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading channels...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : channels.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No channels found</p>
<p className='text-muted-foreground text-xs'>
The bot needs access to view channels in this server
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching channels</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Channels
</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.name}`}
onSelect={() => handleSelectChannel(channel)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='text-muted-foreground'>#</span>
<span className='truncate font-normal'>{channel.name}</span>
</div>
{channel.id === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Channel preview */}
{showPreview && selectedChannel && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-muted/20'>
<span className='font-semibold text-muted-foreground'>#</span>
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>{selectedChannel.name}</h4>
<div className='text-muted-foreground text-xs'>Channel ID: {selectedChannel.id}</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,5 @@
export type { ConfluenceFileInfo } from './confluence-file-selector'
export { ConfluenceFileSelector } from './confluence-file-selector'
export type { DiscordChannelInfo } from './discord-channel-selector'
export { DiscordChannelSelector } from './discord-channel-selector'
export type { GoogleCalendarInfo } from './google-calendar-selector'
export { GoogleCalendarSelector } from './google-calendar-selector'
export type { FileInfo } from './google-drive-picker'

View File

@@ -6,7 +6,6 @@ import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
DiscordChannelSelector,
GoogleCalendarSelector,
GoogleDrivePicker,
JiraIssueSelector,
@@ -70,8 +69,6 @@ export function FileSelectorInput({
const [planIdValue] = useSubBlockValue(blockId, 'planId')
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
const [operationValue] = useSubBlockValue(blockId, 'operation')
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
@@ -87,7 +84,6 @@ export function FileSelectorInput({
const provider = subBlock.provider || 'google-drive'
const isConfluence = provider === 'confluence'
const isJira = provider === 'jira'
const isDiscord = provider === 'discord'
const isMicrosoftTeams = provider === 'microsoft-teams'
const isMicrosoftExcel = provider === 'microsoft-excel'
const isMicrosoftWord = provider === 'microsoft-word'
@@ -108,13 +104,7 @@ export function FileSelectorInput({
''
: ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
: ''
const serverId = isDiscord
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
: ''
// Discord channel selector removed; no special values used here
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
@@ -154,31 +144,6 @@ export function FileSelectorInput({
)
}
// Render Discord channel selector
if (isDiscord) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordChannelSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(channelId) => setStoreValue(channelId)}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={finalDisabled}
showPreview={true}
/>
</div>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
)
}
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (connectedCredential as string) || ''

View File

@@ -14,7 +14,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('LongInput')
@@ -382,11 +381,6 @@ export function LongInput({
onScroll={handleScroll}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
onBlur={() => {
try {
useOperationQueueStore.getState().flushDebouncedForBlock(blockId)
} catch {}
}}
onFocus={() => {
setShowEnvVars(false)
setShowTags(false)
@@ -426,7 +420,7 @@ export function LongInput({
}
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
aria-label='Generate content with AI'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
>
<Wand2 className='h-4 w-4' />
</Button>

View File

@@ -1,324 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { DiscordIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DiscordServerSelector')
export interface DiscordServerInfo {
id: string
name: string
icon?: string | null
}
interface DiscordServerSelectorProps {
value: string
onChange: (value: string, serverInfo?: DiscordServerInfo) => void
botToken: string
label?: string
disabled?: boolean
showPreview?: boolean
}
export function DiscordServerSelector({
value,
onChange,
botToken,
label = 'Select Discord server',
disabled = false,
showPreview = true,
}: DiscordServerSelectorProps) {
const [open, setOpen] = useState(false)
const [servers, setServers] = useState<DiscordServerInfo[]>([])
const [selectedServerId, setSelectedServerId] = useState(value)
const [selectedServer, setSelectedServer] = useState<DiscordServerInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Fetch servers from Discord API
const fetchServers = useCallback(async () => {
if (!botToken) {
setError('Bot token is required')
return
}
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/discord/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord servers')
}
const data = await response.json()
setServers(data.servers || [])
// If we have a selected server ID, find the server info
if (selectedServerId) {
const serverInfo = data.servers?.find(
(server: DiscordServerInfo) => server.id === selectedServerId
)
if (serverInfo) {
setSelectedServer(serverInfo)
}
}
} catch (error) {
logger.error('Error fetching servers:', error)
setError((error as Error).message)
setServers([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [botToken, selectedServerId])
// Handle open change - only fetch servers when the dropdown is opened
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch servers when opening the dropdown and if we have a valid token
if (isOpen && botToken && (!initialFetchDone || servers.length === 0)) {
fetchServers()
}
}
// Fetch only the selected server info when component mounts or when selectedServerId changes
// This is more efficient than fetching all servers
const fetchSelectedServerInfo = useCallback(async () => {
if (!botToken || !selectedServerId) return
setIsLoading(true)
setError(null)
try {
// Only fetch the specific server by ID instead of all servers
const response = await fetch('/api/tools/discord/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
botToken,
serverId: selectedServerId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Discord server')
}
const data = await response.json()
if (data.server) {
setSelectedServer(data.server)
} else if (data.servers && data.servers.length > 0) {
const serverInfo = data.servers.find(
(server: DiscordServerInfo) => server.id === selectedServerId
)
if (serverInfo) {
setSelectedServer(serverInfo)
}
}
} catch (error) {
logger.error('Error fetching server info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
}, [botToken, selectedServerId])
// Fetch selected server info when component mounts or selectedServerId changes
useEffect(() => {
if (value && botToken && (!selectedServer || selectedServer.id !== value)) {
fetchSelectedServerInfo()
}
}, [value, botToken, selectedServer, fetchSelectedServerInfo])
// Sync with external value
useEffect(() => {
if (value !== selectedServerId) {
setSelectedServerId(value)
// Find server info for the new value
if (value && servers.length > 0) {
const serverInfo = servers.find((server) => server.id === value)
setSelectedServer(serverInfo || null)
} else if (value) {
// If we have a value but no server info, we might need to fetch it
if (!selectedServer || selectedServer.id !== value) {
fetchSelectedServerInfo()
}
} else {
setSelectedServer(null)
}
}
}, [value, servers, selectedServerId, selectedServer, fetchSelectedServerInfo])
// Handle server selection
const handleSelectServer = (server: DiscordServerInfo) => {
setSelectedServerId(server.id)
setSelectedServer(server)
onChange(server.id, server)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedServerId('')
setSelectedServer(null)
onChange('', undefined)
setError(null)
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !botToken}
>
{selectedServer ? (
<div className='flex items-center gap-2 overflow-hidden'>
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{selectedServer.name}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<DiscordIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search servers...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading servers...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : servers.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No servers found</p>
<p className='text-muted-foreground text-xs'>
Make sure your bot is added to at least one server
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching servers</p>
</div>
)}
</CommandEmpty>
{servers.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Servers
</div>
{servers.map((server) => (
<CommandItem
key={server.id}
value={`server-${server.id}-${server.name}`}
onSelect={() => handleSelectServer(server)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
{server.icon ? (
<img
src={server.icon}
alt={server.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{server.name}</span>
</div>
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Server preview */}
{showPreview && selectedServer && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-muted/20'>
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className='h-4 w-4 rounded-full'
/>
) : (
<DiscordIcon className='h-4 w-4' />
)}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>{selectedServer.name}</h4>
<div className='text-muted-foreground text-xs'>Server ID: {selectedServer.id}</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -2,10 +2,6 @@
import { useEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
type DiscordServerInfo,
DiscordServerSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector'
import {
type JiraProjectInfo,
JiraProjectSelector,
@@ -44,7 +40,7 @@ export function ProjectSelectorInput({
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
@@ -60,14 +56,12 @@ export function ProjectSelectorInput({
// Get provider-specific values
const provider = subBlock.provider || 'jira'
const isDiscord = provider === 'discord'
const isLinear = provider === 'linear'
// Jira/Discord upstream fields
const [jiraDomain] = useSubBlockValue(blockId, 'domain')
const [jiraCredential] = useSubBlockValue(blockId, 'credential')
const domain = (jiraDomain as string) || ''
const botToken = ''
// Verify Jira credential belongs to current user; if not, treat as absent
@@ -85,7 +79,7 @@ export function ProjectSelectorInput({
// Handle project selection
const handleProjectChange = (
projectId: string,
info?: JiraProjectInfo | DiscordServerInfo | LinearTeamInfo | LinearProjectInfo
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
) => {
setSelectedProjectId(projectId)
setProjectInfo(info || null)
@@ -94,34 +88,7 @@ export function ProjectSelectorInput({
onProjectSelect?.(projectId)
}
// Render Discord server selector if provider is discord
if (isDiscord) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordServerSelector
value={selectedProjectId}
onChange={(serverId: string, serverInfo?: DiscordServerInfo) => {
handleProjectChange(serverId, serverInfo)
}}
botToken={botToken}
label={subBlock.placeholder || 'Select Discord server'}
disabled={disabled || !botToken}
showPreview={true}
/>
</div>
</TooltipTrigger>
{!botToken && (
<TooltipContent side='top'>
<p>Please enter a Bot Token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Discord no longer uses a server selector; fall through to other providers
// Render Linear team/project selector if provider is linear
if (isLinear) {

View File

@@ -14,7 +14,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('ShortInput')
@@ -396,9 +395,6 @@ export function ShortInput({
onBlur={() => {
setIsFocused(false)
setShowEnvVars(false)
try {
useOperationQueueStore.getState().flushDebouncedForBlock(blockId)
} catch {}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
@@ -436,7 +432,7 @@ export function ShortInput({
}
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
aria-label='Generate content with AI'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
>
<Wand2 className='h-4 w-4' />
</Button>

View File

@@ -6,6 +6,7 @@ import 'prismjs/components/prism-json'
import 'prismjs/themes/prism.css'
import { Wand2 } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface CodeEditorProps {
@@ -213,19 +214,16 @@ export function CodeEditor({
)}
>
{showWandButton && onWandClick && (
<button
<Button
variant='ghost'
size='icon'
onClick={onWandClick}
disabled={wandButtonDisabled}
className={cn(
'absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-transparent bg-muted/80 p-0 text-foreground shadow-sm transition-all duration-200',
'hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow',
'opacity-0 transition-opacity group-hover:opacity-100',
wandButtonDisabled && 'cursor-not-allowed opacity-50'
)}
aria-label='Generate with AI'
className='absolute top-2 right-3 z-10 h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground opacity-0 shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow group-hover:opacity-100'
>
<Wand2 className='h-4 w-4' />
</button>
</Button>
)}
{!showWandButton && code.split('\n').length > 5 && (

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Code, FileJson, Trash2, X } from 'lucide-react'
import { AlertTriangle, Code, FileJson, Trash2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -934,11 +934,18 @@ try {
<Label htmlFor='json-schema' className='font-medium'>
JSON Schema
</Label>
{schemaError &&
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className='h-4 w-4 cursor-pointer text-destructive' />
</TooltipTrigger>
<TooltipContent side='top'>
<p>Invalid JSON</p>
</TooltipContent>
</Tooltip>
)}
</div>
{schemaError &&
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
<div className='ml-4 break-words text-red-600 text-sm'>{schemaError}</div>
)}
</div>
<CodeEditor
value={jsonSchema}
@@ -975,7 +982,6 @@ try {
}`}
minHeight='360px'
className={cn(
schemaError && !schemaGeneration.isStreaming ? 'border-red-500' : '',
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}

View File

@@ -18,7 +18,7 @@ interface UseSubBlockValueOptions {
/**
* Custom hook to get and set values for a sub-block in a workflow.
* Handles complex object values properly by using deep equality comparison.
* Includes automatic debouncing and explicit streaming mode for AI generation.
* Supports explicit streaming mode for AI generation.
*
* @param blockId The ID of the block containing the sub-block
* @param subBlockId The ID of the sub-block
@@ -125,6 +125,12 @@ export function useSubBlockValue<T = any>(
return
}
const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!currentActiveWorkflowId) {
logger.warn('No active workflow ID when setting value', { blockId, subBlockId })
return
}
// Use deep comparison to avoid unnecessary updates for complex objects
if (!isEqual(valueRef.current, newValue)) {
valueRef.current = newValue
@@ -147,10 +153,10 @@ export function useSubBlockValue<T = any>(
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId || '']: {
...state.workflowValues[activeWorkflowId || ''],
[currentActiveWorkflowId]: {
...state.workflowValues[currentActiveWorkflowId],
[blockId]: {
...state.workflowValues[activeWorkflowId || '']?.[blockId],
...state.workflowValues[currentActiveWorkflowId]?.[blockId],
[subBlockId]: newValue,
},
},
@@ -175,7 +181,7 @@ export function useSubBlockValue<T = any>(
}
}
// Emit immediately - let the operation queue handle debouncing and deduplication
// Emit immediately; the client queue coalesces same-key ops and the server debounces
emitValue(valueCopy)
if (triggerWorkflowUpdate) {
@@ -194,7 +200,6 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isShowingDiff,
activeWorkflowId,
]
)

View File

@@ -867,52 +867,41 @@ const WorkflowContent = React.memo(() => {
[project, isPointInLoopNodeWrapper, getNodes]
)
// Track when workflow is fully ready for rendering
// Initialize workflow when it exists in registry and isn't active
useEffect(() => {
const currentId = params.workflowId as string
if (!currentId || !workflows[currentId]) return
// Reset workflow ready state when workflow changes
if (activeWorkflowId !== currentId) {
setIsWorkflowReady(false)
return
// Clear diff and set as active
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
setActiveWorkflow(currentId)
}
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
// Check if we have the necessary data to render the workflow
const hasActiveWorkflow = activeWorkflowId === currentId
const hasWorkflowInRegistry = Boolean(workflows[currentId])
const isNotLoading = !isLoading
// Track when workflow is ready for rendering
useEffect(() => {
const currentId = params.workflowId as string
// Workflow is ready when:
// 1. We have an active workflow that matches the URL
// 2. The workflow exists in the registry
// 3. Workflows are not currently loading
if (hasActiveWorkflow && hasWorkflowInRegistry && isNotLoading) {
// Add a small delay to ensure blocks state has settled
const timeoutId = setTimeout(() => {
setIsWorkflowReady(true)
}, 100)
const shouldBeReady =
activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
return () => clearTimeout(timeoutId)
}
setIsWorkflowReady(false)
setIsWorkflowReady(shouldBeReady)
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
// Init workflow
// Handle navigation and validation
useEffect(() => {
const validateAndNavigate = async () => {
const workflowIds = Object.keys(workflows)
const currentId = params.workflowId as string
// Check if workflows have been initially loaded at least once
// This prevents premature navigation decisions on page refresh
if (!hasWorkflowsInitiallyLoaded()) {
logger.info('Waiting for initial workflow load...')
return
}
// Wait for both initialization and workflow loading to complete
if (isLoading) {
logger.info('Workflows still loading, waiting...')
// Wait for initial load to complete before making navigation decisions
if (!hasWorkflowsInitiallyLoaded() || isLoading) {
return
}
@@ -952,24 +941,10 @@ const WorkflowContent = React.memo(() => {
router.replace(`/workspace/${currentWorkflow.workspaceId}/w/${currentId}`)
return
}
// Get current active workflow state
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId !== currentId) {
// Clear workflow diff store when switching workflows
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
setActiveWorkflow(currentId)
} else {
// Don't reset variables cache if we're not actually switching workflows
setActiveWorkflow(currentId)
}
}
validateAndNavigate()
}, [params.workflowId, workflows, isLoading, setActiveWorkflow, createWorkflow, router])
}, [params.workflowId, workflows, isLoading, workspaceId, router])
// Transform blocks and loops into ReactFlow nodes
const nodes = useMemo(() => {

View File

@@ -128,7 +128,7 @@ export function WorkflowItem({
}
const handleClick = (e: React.MouseEvent) => {
if (dragStartedRef.current || isEditing) {
if (isDragging || isEditing) {
e.preventDefault()
return
}

View File

@@ -79,7 +79,11 @@ export function UsageHeader({
</div>
</div>
<Progress value={isBlocked ? 100 : progress} className='h-2' />
<Progress
value={isBlocked ? 100 : progress}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
{isBlocked && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>

View File

@@ -1,7 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Skeleton } from '@/components/ui'
import { Skeleton, Switch } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
import { cn } from '@/lib/utils'
@@ -500,6 +499,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)}
{/* Billing usage notifications toggle */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{subscription.isEnterprise && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
@@ -527,3 +529,42 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)
}
function BillingUsageNotificationsToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null)
useEffect(() => {
let isMounted = true
const load = async () => {
const res = await fetch('/api/users/me/settings')
const json = await res.json()
const current = json?.data?.billingUsageNotificationsEnabled
if (isMounted) setEnabled(current !== false)
}
load()
return () => {
isMounted = false
}
}, [])
const update = async (next: boolean) => {
setEnabled(next)
await fetch('/api/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingUsageNotificationsEnabled: next }),
})
}
if (enabled === null) return null
return (
<div className='mt-4 flex items-center justify-between'>
<div className='flex flex-col'>
<span className='font-medium text-sm'>Usage notifications</span>
<span className='text-muted-foreground text-xs'>Email me when I reach 80% usage</span>
</div>
<Switch checked={enabled} onCheckedChange={(v: boolean) => update(v)} />
</div>
)
}

View File

@@ -100,9 +100,11 @@ export function TeamSeatsOverview({
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Seats</span>
<span className='text-muted-foreground text-xs'>
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
</span>
{!checkEnterprisePlan(subscriptionData) ? (
<span className='text-muted-foreground text-xs'>
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
</span>
) : null}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<span className='text-muted-foreground'>{usedSeats} used</span>

View File

@@ -92,8 +92,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
</span>
</div>
{/* Progress Bar with color: yellow for warning, red for full/blocked */}
<Progress value={isBlocked ? 100 : progressPercentage} className='h-2' />
{/* Progress Bar */}
<Progress
value={isBlocked ? 100 : progressPercentage}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
'use client'
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -60,6 +60,7 @@ interface PermissionsTableProps {
onPermissionChange: (userId: string, permissionType: PermissionType) => void
onRemoveMember?: (userId: string, email: string) => void
onRemoveInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string, email: string) => void
disabled?: boolean
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -67,6 +68,9 @@ interface PermissionsTableProps {
permissionsLoading: boolean
pendingInvitations: UserPermissions[]
isPendingInvitationsLoading: boolean
resendingInvitationIds?: Record<string, boolean>
resentInvitationIds?: Record<string, boolean>
resendCooldowns?: Record<string, number>
}
interface PendingInvitation {
@@ -159,13 +163,18 @@ PermissionSelector.displayName = 'PermissionSelector'
const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-2 py-2'>
{/* Email skeleton - matches the actual email span dimensions */}
<Skeleton className='h-5 w-40' />
{/* Permission selector skeleton - matches PermissionSelector exact height */}
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
</div>
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className='flex items-center justify-between gap-2 py-2'>
<Skeleton className='h-5 w-40' />
<div className='flex items-center gap-2'>
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
<div className='flex w-10 items-center gap-1 sm:w-12'>
<Skeleton className='h-4 w-4 rounded' />
<Skeleton className='h-4 w-4 rounded' />
</div>
</div>
</div>
))}
</div>
))
@@ -183,6 +192,10 @@ const PermissionsTable = ({
permissionsLoading,
pendingInvitations,
isPendingInvitationsLoading,
onResendInvitation,
resendingInvitationIds,
resentInvitationIds,
resendCooldowns,
}: PermissionsTableProps) => {
const { data: session } = useSession()
const userPerms = useUserPermissionsContext()
@@ -309,8 +322,21 @@ const PermissionsTable = ({
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
Sent
<span className='inline-flex items-center gap-1 rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
{resendingInvitationIds &&
user.invitationId &&
resendingInvitationIds[user.invitationId] ? (
<>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Sending...</span>
</>
) : resentInvitationIds &&
user.invitationId &&
resentInvitationIds[user.invitationId] ? (
<span>Resent</span>
) : (
<span>Sent</span>
)}
</span>
)}
{hasChanges && (
@@ -321,7 +347,7 @@ const PermissionsTable = ({
</div>
</div>
{/* Permission selector and remove button container */}
{/* Permission selector and fixed-width action area to keep rows aligned */}
<div className='flex flex-shrink-0 items-center gap-2'>
<PermissionSelector
value={user.permissionType}
@@ -335,8 +361,45 @@ const PermissionsTable = ({
className='w-auto'
/>
{/* X button with consistent spacing - always reserve space */}
<div className='flex h-4 w-4 items-center justify-center'>
{/* Fixed-width action area so selector stays inline across rows */}
<div className='flex h-4 w-10 items-center justify-center gap-1 sm:w-12'>
{isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onResendInvitation && (
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
size='icon'
onClick={() => onResendInvitation(user.invitationId!, user.email)}
disabled={
disabled ||
isSaving ||
resendingInvitationIds?.[user.invitationId!] ||
(resendCooldowns && resendCooldowns[user.invitationId!] > 0)
}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{resendingInvitationIds?.[user.invitationId!] ? (
<Loader2 className='h-3.5 w-3.5 animate-spin' />
) : (
<RotateCw className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Resend invite</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{resendCooldowns?.[user.invitationId!]
? `Resend in ${resendCooldowns[user.invitationId!]}s`
: 'Resend invite'}
</p>
</TooltipContent>
</Tooltip>
)}
{((canShowRemoveButton && onRemoveMember) ||
(isPendingInvitation &&
currentUserIsAdmin &&
@@ -408,6 +471,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
email: string
} | null>(null)
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -748,6 +814,72 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvitationToRemove(null)
}, [])
const handleResendInvitation = useCallback(
async (invitationId: string, email: string) => {
if (!workspaceId || !userPerms.canAdmin) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setErrorMessage(null)
try {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to resend invitation')
}
setSuccessMessage(`Invitation resent to ${email}`)
setTimeout(() => setSuccessMessage(null), 3000)
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
} catch (error) {
logger.error('Error resending invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
}
},
[workspaceId, userPerms.canAdmin, resendCooldowns]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
@@ -989,6 +1121,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
onResendInvitation={handleResendInvitation}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
@@ -996,6 +1129,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
resendingInvitationIds={resendingInvitationIds}
resentInvitationIds={resentInvitationIds}
resendCooldowns={resendCooldowns}
/>
</form>

View File

@@ -691,21 +691,13 @@ export function Sidebar() {
}
})
// Sort by last modified date (newest first)
const sortByLastModified = (a: WorkflowMetadata, b: WorkflowMetadata) => {
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 by creation date (newest first) for stable ordering
const sortByCreatedAt = (a: WorkflowMetadata, b: WorkflowMetadata) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}
regular.sort(sortByLastModified)
temp.sort(sortByLastModified)
regular.sort(sortByCreatedAt)
temp.sort(sortByCreatedAt)
}
return { regularWorkflows: regular, tempWorkflows: temp }

View File

@@ -7,7 +7,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export default function WorkflowsPage() {
const router = useRouter()
const { workflows, isLoading, loadWorkflows } = useWorkflowRegistry()
const { workflows, isLoading, loadWorkflows, setActiveWorkflow } = useWorkflowRegistry()
const [hasInitialized, setHasInitialized] = useState(false)
const params = useParams()
@@ -45,9 +45,14 @@ export default function WorkflowsPage() {
// If we have valid workspace workflows, redirect to the first one
if (workspaceWorkflows.length > 0) {
router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0]}`)
// Ensure the workflow is set as active before redirecting
// This prevents the empty canvas issue on first login
const firstWorkflowId = workspaceWorkflows[0]
setActiveWorkflow(firstWorkflowId).then(() => {
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
})
}
}, [hasInitialized, isLoading, workflows, workspaceId, router])
}, [hasInitialized, isLoading, workflows, workspaceId, router, setActiveWorkflow])
// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"

View File

@@ -154,7 +154,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
// Construct parameters based on operation
const baseParams = {
accessToken: credential,
credential,
...rest,
}

View File

@@ -55,6 +55,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
title: 'Select Page',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'pageId',
provider: 'confluence',
serviceId: 'confluence',
placeholder: 'Select Confluence page',
@@ -67,6 +68,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
title: 'Page ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'pageId',
placeholder: 'Enter Confluence page ID',
mode: 'advanced',
},
@@ -112,7 +114,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
}
return {
accessToken: credential,
credential,
pageId: effectivePageId,
...rest,
}

View File

@@ -34,56 +34,30 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
password: true,
required: true,
},
// Server selector (basic mode)
{
id: 'serverId',
title: 'Server',
type: 'project-selector',
layout: 'full',
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord server',
dependsOn: ['botToken'],
mode: 'basic',
condition: {
field: 'operation',
value: ['discord_send_message', 'discord_get_messages', 'discord_get_server'],
},
},
// Manual server ID input (advanced mode)
{
id: 'manualServerId',
title: 'Server ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Discord server ID',
mode: 'advanced',
required: true,
provider: 'discord',
serviceId: 'discord',
condition: {
field: 'operation',
value: ['discord_send_message', 'discord_get_messages', 'discord_get_server'],
},
},
// Channel selector (basic mode)
// Channel ID (single input used in all modes)
{
id: 'channelId',
title: 'Channel',
type: 'file-selector',
layout: 'full',
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord channel',
dependsOn: ['botToken', 'serverId'],
mode: 'basic',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},
// Manual channel ID input (advanced mode)
{
id: 'manualChannelId',
title: 'Channel ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Discord channel ID',
mode: 'advanced',
required: true,
provider: 'discord',
serviceId: 'discord',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},
{
@@ -139,56 +113,44 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
if (!params.botToken) throw new Error('Bot token required for this operation')
commonParams.botToken = params.botToken
// Handle server ID (selector or manual)
const effectiveServerId = (params.serverId || params.manualServerId || '').trim()
// Handle channel ID (selector or manual)
const effectiveChannelId = (params.channelId || params.manualChannelId || '').trim()
// Single inputs
const serverId = (params.serverId || '').trim()
const channelId = (params.channelId || '').trim()
switch (params.operation) {
case 'discord_send_message':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required. Please select a channel or enter a channel ID manually.'
)
if (!channelId) {
throw new Error('Channel ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
channelId: effectiveChannelId,
serverId,
channelId,
content: params.content,
}
case 'discord_get_messages':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required. Please select a channel or enter a channel ID manually.'
)
if (!channelId) {
throw new Error('Channel ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
channelId: effectiveChannelId,
serverId,
channelId,
limit: params.limit ? Math.min(Math.max(1, Number(params.limit)), 100) : 10,
}
case 'discord_get_server':
if (!effectiveServerId) {
throw new Error(
'Server ID is required. Please select a server or enter a server ID manually.'
)
if (!serverId) {
throw new Error('Server ID is required.')
}
return {
...commonParams,
serverId: effectiveServerId,
serverId,
}
case 'discord_get_user':
return {
@@ -205,9 +167,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
operation: { type: 'string', description: 'Operation to perform' },
botToken: { type: 'string', description: 'Discord bot token' },
serverId: { type: 'string', description: 'Discord server identifier' },
manualServerId: { type: 'string', description: 'Manual server identifier' },
channelId: { type: 'string', description: 'Discord channel identifier' },
manualChannelId: { type: 'string', description: 'Manual channel identifier' },
content: { type: 'string', description: 'Message content' },
limit: { type: 'number', description: 'Message limit' },
userId: { type: 'string', description: 'Discord user identifier' },

View File

@@ -99,6 +99,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
title: 'Label',
type: 'folder-selector',
layout: 'full',
canonicalParamId: 'folder',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
@@ -116,6 +117,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
title: 'Label/Folder',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folder',
placeholder: 'Enter Gmail label name (e.g., INBOX, SENT, or custom label)',
mode: 'advanced',
condition: { field: 'operation', value: 'read_gmail' },
@@ -195,13 +197,11 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
}
},
params: (params) => {
// Pass the credential directly from the credential field
const { credential, folder, manualFolder, ...rest } = params
// Handle folder input (selector or manual)
// Handle both selector and manual folder input
const effectiveFolder = (folder || manualFolder || '').trim()
// Ensure folder is always provided for read_gmail operation
if (rest.operation === 'read_gmail') {
rest.folder = effectiveFolder || 'INBOX'
}

View File

@@ -44,6 +44,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
title: 'Calendar',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'calendarId',
provider: 'google-calendar',
serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
@@ -57,6 +58,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
title: 'Calendar ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'calendarId',
placeholder: 'Enter calendar ID (e.g., primary or calendar@gmail.com)',
mode: 'advanced',
},
@@ -269,7 +271,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
}
return {
accessToken: credential,
credential,
...processedParams,
}
},

View File

@@ -44,6 +44,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Select Document',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'documentId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Document ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'documentId',
placeholder: 'Enter document ID',
dependsOn: ['credential'],
mode: 'advanced',
@@ -80,6 +82,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -95,6 +98,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -139,13 +143,14 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
const { credential, documentId, manualDocumentId, folderSelector, folderId, ...rest } =
params
// Handle both selector and manual inputs
const effectiveDocumentId = (documentId || manualDocumentId || '').trim()
const effectiveFolderId = (folderSelector || folderId || '').trim()
return {
...rest,
documentId: effectiveDocumentId,
folderId: effectiveFolderId,
documentId: effectiveDocumentId || undefined,
folderId: effectiveFolderId || undefined,
credential,
}
},

View File

@@ -76,6 +76,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -90,6 +91,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'upload' },
@@ -150,6 +152,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -165,6 +168,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'create_folder' },
@@ -175,6 +179,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Select Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
@@ -190,6 +195,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
title: 'Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter folder ID (leave empty for root folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
@@ -233,8 +239,8 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
const effectiveFolderId = (folderSelector || manualFolderId || '').trim()
return {
accessToken: credential,
folderId: effectiveFolderId,
credential,
folderId: effectiveFolderId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
...rest,

View File

@@ -45,6 +45,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'spreadsheetId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: [],
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
title: 'Spreadsheet ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'spreadsheetId',
placeholder: 'ID of the spreadsheet (from URL)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -174,18 +176,13 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
params: (params) => {
const { credential, values, spreadsheetId, manualSpreadsheetId, ...rest } = params
// Parse values from JSON string to array if it exists
const parsedValues = values ? JSON.parse(values as string) : undefined
// Use the selected spreadsheet ID or the manually entered one
// If spreadsheetId is provided, it's from the file selector and contains the file ID
// If not, fall back to manually entered ID
// Handle both selector and manual input
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
if (!effectiveSpreadsheetId) {
throw new Error(
'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.'
)
throw new Error('Spreadsheet ID is required.')
}
return {

View File

@@ -20,7 +20,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
layout: 'full',
options: [
{ label: 'Read Issue', id: 'read' },
{ label: 'Read Issues', id: 'read-bulk' },
{ label: 'Update Issue', id: 'update' },
{ label: 'Write Issue', id: 'write' },
],
@@ -59,6 +58,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Select Project',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'projectId',
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira project',
@@ -71,6 +71,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Project ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'projectId',
placeholder: 'Enter Jira project ID',
dependsOn: ['credential', 'domain'],
mode: 'advanced',
@@ -81,6 +82,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Select Issue',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'issueKey',
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira issue',
@@ -94,8 +96,9 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
title: 'Issue Key',
type: 'short-input',
layout: 'full',
canonicalParamId: 'issueKey',
placeholder: 'Enter Jira issue key',
dependsOn: ['credential', 'domain', 'projectId'],
dependsOn: ['credential', 'domain', 'projectId', 'manualProjectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'advanced',
},
@@ -123,8 +126,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
access: ['jira_retrieve', 'jira_update', 'jira_write', 'jira_bulk_read'],
config: {
tool: (params) => {
const effectiveProjectId = (params.projectId || params.manualProjectId || '').trim()
const effectiveIssueKey = (params.issueKey || params.manualIssueKey || '').trim()
switch (params.operation) {
case 'read':
// If a project is selected but no issue is chosen, route to bulk read
if (effectiveProjectId && !effectiveIssueKey) {
return 'jira_bulk_read'
}
return 'jira_retrieve'
case 'update':
return 'jira_update'
@@ -139,19 +149,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
params: (params) => {
const { credential, projectId, manualProjectId, issueKey, manualIssueKey, ...rest } = params
// Base params that are always needed
// Use the selected IDs or the manually entered ones
const effectiveProjectId = (projectId || manualProjectId || '').trim()
const effectiveIssueKey = (issueKey || manualIssueKey || '').trim()
const baseParams = {
accessToken: credential,
credential,
domain: params.domain,
}
// Use the selected project ID or the manually entered one
const effectiveProjectId = (projectId || manualProjectId || '').trim()
// Use the selected issue key or the manually entered one
const effectiveIssueKey = (issueKey || manualIssueKey || '').trim()
// Define allowed parameters for each operation
switch (params.operation) {
case 'write': {
if (!effectiveProjectId) {
@@ -159,8 +165,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'Project ID is required. Please select a project or enter a project ID manually.'
)
}
// For write operations, only include write-specific fields
const writeParams = {
projectId: effectiveProjectId,
summary: params.summary || '',
@@ -168,7 +172,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
issueType: params.issueType || 'Task',
parent: params.parentIssue ? { key: params.parentIssue } : undefined,
}
return {
...baseParams,
...writeParams,
@@ -185,44 +188,46 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'Issue Key is required. Please select an issue or enter an issue key manually.'
)
}
// For update operations, only include update-specific fields
const updateParams = {
projectId: effectiveProjectId,
issueKey: effectiveIssueKey,
summary: params.summary || '',
description: params.description || '',
}
return {
...baseParams,
...updateParams,
}
}
case 'read': {
if (!effectiveIssueKey) {
// Check for project ID from either source
const projectForRead = (params.projectId || params.manualProjectId || '').trim()
const issueForRead = (params.issueKey || params.manualIssueKey || '').trim()
if (!issueForRead) {
throw new Error(
'Issue Key is required. Please select an issue or enter an issue key manually.'
'Select a project to read issues, or provide an issue key to read a single issue.'
)
}
// For read operations, only include read-specific fields
return {
...baseParams,
issueKey: effectiveIssueKey,
issueKey: issueForRead,
// Include projectId if available for context
...(projectForRead && { projectId: projectForRead }),
}
}
case 'read-bulk': {
if (!effectiveProjectId) {
// Check both projectId and manualProjectId directly from params
const finalProjectId = params.projectId || params.manualProjectId || ''
if (!finalProjectId) {
throw new Error(
'Project ID is required. Please select a project or enter a project ID manually.'
)
}
// For read-bulk operations, only include read-bulk-specific fields
return {
...baseParams,
projectId: effectiveProjectId,
projectId: finalProjectId.trim(),
}
}
default:

View File

@@ -1,7 +1,9 @@
import { LinearIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { BlockConfig, BlockIcon } from '@/blocks/types'
import type { LinearResponse } from '@/tools/linear/types'
const LinearBlockIcon: BlockIcon = (props) => LinearIcon(props as any)
export const LinearBlock: BlockConfig<LinearResponse> = {
type: 'linear',
name: 'Linear',
@@ -9,7 +11,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
longDescription:
'Integrate with Linear to fetch, filter, and create issues directly from your workflow.',
category: 'tools',
icon: LinearIcon,
icon: LinearBlockIcon,
bgColor: '#5E6AD2',
subBlocks: [
{
@@ -39,6 +41,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Team',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'teamId',
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a team',
@@ -50,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Project',
type: 'project-selector',
layout: 'full',
canonicalParamId: 'projectId',
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a project',
@@ -62,6 +66,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Team ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'teamId',
placeholder: 'Enter Linear team ID',
mode: 'advanced',
},
@@ -71,6 +76,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
title: 'Project ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'projectId',
placeholder: 'Enter Linear project ID',
mode: 'advanced',
},
@@ -96,19 +102,15 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
tool: (params) =>
params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues',
params: (params) => {
// Handle team ID (selector or manual)
// Handle both selector and manual inputs
const effectiveTeamId = (params.teamId || params.manualTeamId || '').trim()
// Handle project ID (selector or manual)
const effectiveProjectId = (params.projectId || params.manualProjectId || '').trim()
if (!effectiveTeamId) {
throw new Error('Team ID is required. Please select a team or enter a team ID manually.')
throw new Error('Team ID is required.')
}
if (!effectiveProjectId) {
throw new Error(
'Project ID is required. Please select a project or enter a project ID manually.'
)
throw new Error('Project ID is required.')
}
if (params.operation === 'write') {

View File

@@ -41,6 +41,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'spreadsheetId',
provider: 'microsoft-excel',
serviceId: 'microsoft-excel',
requiredScopes: [],
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
title: 'Spreadsheet ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'spreadsheetId',
placeholder: 'Enter spreadsheet ID',
dependsOn: ['credential'],
mode: 'advanced',
@@ -147,6 +149,9 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
const { credential, values, spreadsheetId, manualSpreadsheetId, tableName, ...rest } =
params
// Handle both selector and manual input
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
// Parse values from JSON string to array if it exists
let parsedValues
try {
@@ -155,13 +160,8 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
throw new Error('Invalid JSON format for values')
}
// Use the selected spreadsheet ID or the manually entered one
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
if (!effectiveSpreadsheetId) {
throw new Error(
'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.'
)
throw new Error('Spreadsheet ID is required.')
}
// For table operations, ensure tableName is provided

View File

@@ -73,11 +73,12 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'basic',
canonicalParamId: 'taskId',
},
// Advanced mode
{
id: 'taskId',
id: 'manualTaskId',
title: 'Manual Task ID',
type: 'short-input',
layout: 'full',
@@ -85,6 +86,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'advanced',
canonicalParamId: 'taskId',
},
{
@@ -147,6 +149,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
operation,
planId,
taskId,
manualTaskId,
title,
description,
dueDateTime,
@@ -160,13 +163,16 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
credential,
}
// Handle both selector and manual task ID
const effectiveTaskId = (taskId || manualTaskId || '').trim()
// For read operations
if (operation === 'read_task') {
const readParams: MicrosoftPlannerBlockParams = { ...baseParams }
// If taskId is provided, add it (highest priority - get specific task)
if (taskId?.trim()) {
readParams.taskId = taskId.trim()
if (effectiveTaskId) {
readParams.taskId = effectiveTaskId
}
// If no taskId but planId is provided, add planId (get tasks from plan)
else if (planId?.trim()) {
@@ -220,6 +226,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
credential: { type: 'string', description: 'Microsoft account credential' },
planId: { type: 'string', description: 'Plan ID' },
taskId: { type: 'string', description: 'Task ID' },
manualTaskId: { type: 'string', description: 'Manual Task ID' },
title: { type: 'string', description: 'Task title' },
description: { type: 'string', description: 'Task description' },
dueDateTime: { type: 'string', description: 'Due date' },

View File

@@ -57,6 +57,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Team',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'teamId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -70,6 +71,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Team ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'teamId',
placeholder: 'Enter team ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
@@ -79,6 +81,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Chat',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'chatId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -92,6 +95,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Chat ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'chatId',
placeholder: 'Enter chat ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
@@ -101,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Select Channel',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'channelId',
provider: 'microsoft-teams',
serviceId: 'microsoft-teams',
requiredScopes: [],
@@ -114,6 +119,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
title: 'Channel ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'channelId',
placeholder: 'Enter channel ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
@@ -177,42 +183,27 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
const effectiveChatId = (chatId || manualChatId || '').trim()
const effectiveChannelId = (channelId || manualChannelId || '').trim()
// Build the parameters based on operation type
const baseParams = {
...rest,
credential,
}
// For chat operations, we need chatId
if (operation === 'read_chat' || operation === 'write_chat') {
// Don't pass empty chatId - let the tool handle the error
if (!effectiveChatId) {
throw new Error(
'Chat ID is required for chat operations. Please select a chat or enter a chat ID manually.'
)
}
return {
...baseParams,
chatId: effectiveChatId,
throw new Error('Chat ID is required. Please select a chat or enter a chat ID.')
}
return { ...baseParams, chatId: effectiveChatId }
}
// For channel operations, we need teamId and channelId
if (operation === 'read_channel' || operation === 'write_channel') {
if (!effectiveTeamId) {
throw new Error(
'Team ID is required for channel operations. Please select a team or enter a team ID manually.'
)
throw new Error('Team ID is required for channel operations.')
}
if (!effectiveChannelId) {
throw new Error(
'Channel ID is required for channel operations. Please select a channel or enter a channel ID manually.'
)
}
return {
...baseParams,
teamId: effectiveTeamId,
channelId: effectiveChannelId,
throw new Error('Channel ID is required for channel operations.')
}
return { ...baseParams, teamId: effectiveTeamId, channelId: effectiveChannelId }
}
return baseParams

View File

@@ -282,7 +282,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
return {
...rest,
accessToken: credential,
credential,
...(parsedProperties ? { properties: parsedProperties } : {}),
...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}),
...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}),

View File

@@ -66,6 +66,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -105,6 +107,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Parent Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -127,6 +130,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Parent Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -138,6 +142,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Select Folder',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'folderId',
provider: 'microsoft',
serviceId: 'onedrive',
requiredScopes: [
@@ -160,6 +165,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
title: 'Folder ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folderId',
placeholder: 'Enter folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -200,12 +206,13 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
params: (params) => {
const { credential, folderSelector, manualFolderId, mimeType, ...rest } = params
// Use folderSelector if provided, otherwise use manualFolderId
const effectiveFolderId = (folderSelector || manualFolderId || '').trim()
return {
credential,
...rest,
accessToken: credential,
// Pass both; tools will prioritize manualFolderId over folderSelector
folderSelector,
manualFolderId,
folderId: effectiveFolderId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
}

View File

@@ -142,6 +142,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
title: 'Folder',
type: 'folder-selector',
layout: 'full',
canonicalParamId: 'folder',
provider: 'outlook',
serviceId: 'outlook',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
@@ -156,6 +157,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
title: 'Folder',
type: 'short-input',
layout: 'full',
canonicalParamId: 'folder',
placeholder: 'Enter Outlook folder name (e.g., INBOX, SENT, or custom folder)',
mode: 'advanced',
condition: { field: 'operation', value: 'read_outlook' },
@@ -196,13 +198,11 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
}
},
params: (params) => {
// Pass the credential directly from the credential field
const { credential, folder, manualFolder, ...rest } = params
// Handle folder input (selector or manual)
// Handle both selector and manual folder input
const effectiveFolder = (folder || manualFolder || '').trim()
// Set default folder to INBOX if not specified
if (rest.operation === 'read_outlook') {
rest.folder = effectiveFolder || 'INBOX'
}

View File

@@ -18,7 +18,7 @@ const getCurrentOllamaModels = () => {
interface RouterResponse extends ToolResponse {
output: {
content: string
prompt: string
model: string
tokens?: {
prompt?: number
@@ -198,7 +198,6 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
hidden: true,
min: 0,
max: 2,
value: () => '0.1',
},
{
id: 'systemPrompt',
@@ -246,7 +245,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
},
},
outputs: {
content: { type: 'string', description: 'Routing response content' },
prompt: { type: 'string', description: 'Routing prompt used' },
model: { type: 'string', description: 'Model used' },
tokens: { type: 'json', description: 'Token usage' },
cost: { type: 'json', description: 'Cost information' },

View File

@@ -49,6 +49,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
title: 'Select Site',
type: 'file-selector',
layout: 'full',
canonicalParamId: 'siteId',
provider: 'microsoft',
serviceId: 'sharepoint',
requiredScopes: [
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
title: 'Site ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'siteId',
placeholder: 'Enter site ID (leave empty for root site)',
dependsOn: ['credential'],
mode: 'advanced',
@@ -127,8 +129,8 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
const effectiveSiteId = (siteSelector || manualSiteId || '').trim()
return {
accessToken: credential,
siteId: effectiveSiteId,
credential,
siteId: effectiveSiteId || undefined,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
...rest,

View File

@@ -78,6 +78,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
title: 'Channel',
type: 'channel-selector',
layout: 'full',
canonicalParamId: 'channel',
provider: 'slack',
placeholder: 'Select Slack channel',
mode: 'basic',
@@ -89,6 +90,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
title: 'Channel ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'channel',
placeholder: 'Enter Slack channel ID (e.g., C1234567890)',
mode: 'advanced',
},
@@ -192,13 +194,11 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
...rest
} = params
// Handle channel input (selector or manual)
// Handle both selector and manual channel input
const effectiveChannel = (channel || manualChannel || '').trim()
if (!effectiveChannel) {
throw new Error(
'Channel is required. Please select a channel or enter a channel ID manually.'
)
throw new Error('Channel is required.')
}
const baseParams: Record<string, any> = {

View File

@@ -57,6 +57,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
layout: 'full',
placeholder: 'Enter Contact ID',
mode: 'basic',
canonicalParamId: 'contactId',
condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] },
},
{
@@ -64,6 +65,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
title: 'Contact ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'contactId',
placeholder: 'Enter Contact ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] },
@@ -75,6 +77,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
layout: 'full',
placeholder: 'Enter Task ID',
mode: 'basic',
canonicalParamId: 'taskId',
condition: { field: 'operation', value: ['read_task'] },
},
{
@@ -82,6 +85,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
title: 'Task ID',
type: 'short-input',
layout: 'full',
canonicalParamId: 'taskId',
placeholder: 'Enter Task ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_task'] },
@@ -180,19 +184,15 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
const { credential, operation, contactId, manualContactId, taskId, manualTaskId, ...rest } =
params
// Handle contact ID input (selector or manual)
// Handle both selector and manual inputs
const effectiveContactId = (contactId || manualContactId || '').trim()
// Handle task ID input (selector or manual)
const effectiveTaskId = (taskId || manualTaskId || '').trim()
// Build the parameters based on operation type
const baseParams = {
...rest,
credential,
}
// For note operations, we need noteId
if (operation === 'read_note' || operation === 'write_note') {
return {
...baseParams,
@@ -200,8 +200,6 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
contactId: effectiveContactId,
}
}
// For contact operations, we need contactId
if (operation === 'read_contact') {
if (!effectiveContactId) {
throw new Error('Contact ID is required for contact operations')
@@ -211,26 +209,22 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
contactId: effectiveContactId,
}
}
// For task operations, we need taskId
if (operation === 'read_task') {
if (!effectiveTaskId) {
if (!taskId?.trim()) {
throw new Error('Task ID is required for task operations')
}
return {
...baseParams,
taskId: effectiveTaskId,
taskId: taskId.trim(),
}
}
// For write_task and write_note operations, we need contactId
if (operation === 'write_task' || operation === 'write_note') {
if (!effectiveContactId) {
if (!contactId?.trim()) {
throw new Error('Contact ID is required for this operation')
}
return {
...baseParams,
contactId: effectiveContactId,
contactId: contactId.trim(),
}
}

View File

@@ -101,6 +101,7 @@ export interface SubBlockConfig {
type: SubBlockType
layout?: SubBlockLayout
mode?: 'basic' | 'advanced' | 'both' // Default is 'both' if not specified
canonicalParamId?: string
required?: boolean
options?:
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }[]

View File

@@ -13,7 +13,6 @@ import {
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -80,7 +79,7 @@ export const BatchInvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -14,7 +14,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -45,7 +44,7 @@ export const EnterpriseSubscriptionEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
@@ -94,7 +93,7 @@ export const EnterpriseSubscriptionEmail = ({
</Text>
<Text style={baseStyles.paragraph}>
Welcome to Sim Enterprise!
Best regards,
<br />
The Sim Team
</Text>

View File

@@ -13,7 +13,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -60,7 +59,7 @@ export const HelpConfirmationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -5,6 +5,8 @@ export { default as EmailFooter } from './footer'
export { HelpConfirmationEmail } from './help-confirmation-email'
export { InvitationEmail } from './invitation-email'
export { OTPVerificationEmail } from './otp-verification-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export * from './render-email'
export { ResetPasswordEmail } from './reset-password-email'
export { UsageThresholdEmail } from './usage-threshold-email'
export { WorkspaceInvitationEmail } from './workspace-invitation'

View File

@@ -15,7 +15,6 @@ import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -66,7 +65,7 @@ export const InvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -12,7 +12,6 @@ import {
} from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -72,7 +71,7 @@ export const OTPVerificationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -0,0 +1,113 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
createdDate?: Date
}
export function PlanWelcomeEmail({
planName,
userName,
loginLink,
createdDate = new Date(),
}: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const cta = loginLink || `${baseUrl}/login`
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
build, test, and scale your agentic workflows.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
Want to discuss your plan or get personalized help getting started?{' '}
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
Schedule a 15-minute call
</Link>{' '}
with our team.
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {createdDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default PlanWelcomeEmail

View File

@@ -5,7 +5,9 @@ import {
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
PlanWelcomeEmail,
ResetPasswordEmail,
UsageThresholdEmail,
} from '@/components/emails'
import { getBrandConfig } from '@/lib/branding/branding'
@@ -100,6 +102,27 @@ export async function renderEnterpriseSubscriptionEmail(
)
}
export async function renderUsageThresholdEmail(params: {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}): Promise<string> {
return await render(
UsageThresholdEmail({
userName: params.userName,
planName: params.planName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
updatedDate: new Date(),
})
)
}
export function getEmailSubject(
type:
| 'sign-in'
@@ -110,6 +133,9 @@ export function getEmailSubject(
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'plan-welcome-pro'
| 'plan-welcome-team'
): string {
const brandName = getBrandConfig().name
@@ -130,7 +156,28 @@ export function getEmailSubject(
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
default:
return brandName
}
}
export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
}): Promise<string> {
return await render(
PlanWelcomeEmail({
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
createdDate: new Date(),
})
)
}

View File

@@ -14,7 +14,6 @@ import {
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -43,7 +42,7 @@ export const ResetPasswordEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -0,0 +1,123 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
interface UsageThresholdEmailProps {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
updatedDate?: Date
}
export function UsageThresholdEmail({
userName,
planName,
percentUsed,
currentUsage,
limit,
ctaLink,
updatedDate = new Date(),
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
<strong>Usage</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Column>
</Row>
</Section>
<Hr />
<Text style={{ ...baseStyles.paragraph }}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review limits</Text>
</Link>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 80%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default UsageThresholdEmail

View File

@@ -14,7 +14,6 @@ import {
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -64,7 +63,7 @@ export const WorkspaceInvitationEmail = ({
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
width='114'
alt={brand.name}
style={{

View File

@@ -4,21 +4,24 @@ import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className='h-full w-full flex-1 bg-primary transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn('h-full w-full flex-1 bg-primary transition-all', indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -327,105 +327,97 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
}
})
// Shared function to rehydrate workflow stores
const rehydrateWorkflowStores = async (
workflowId: string,
workflowState: any,
source: 'copilot' | 'workflow-state'
) => {
// Import stores dynamically
const [
{ useOperationQueueStore },
{ useWorkflowRegistry },
{ useWorkflowStore },
{ useSubBlockStore },
] = await Promise.all([
import('@/stores/operation-queue/store'),
import('@/stores/workflows/registry/store'),
import('@/stores/workflows/workflow/store'),
import('@/stores/workflows/subblock/store'),
])
// Only proceed if this is the active workflow
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId !== workflowId) {
logger.info(`Skipping rehydration - workflow ${workflowId} is not active`)
return false
}
// Check for pending operations
const hasPending = useOperationQueueStore
.getState()
.operations.some((op: any) => op.workflowId === workflowId && op.status !== 'confirmed')
if (hasPending) {
logger.info(`Skipping ${source} rehydration due to pending operations in queue`)
return false
}
// Extract subblock values from blocks
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
})
})
// Replace local workflow store with authoritative server state
useWorkflowStore.setState({
blocks: workflowState.blocks || {},
edges: workflowState.edges || [],
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
lastSaved: workflowState.lastSaved || Date.now(),
isDeployed: workflowState.isDeployed ?? false,
deployedAt: workflowState.deployedAt,
deploymentStatuses: workflowState.deploymentStatuses || {},
hasActiveWebhook: workflowState.hasActiveWebhook ?? false,
})
// Replace subblock store values for this workflow
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
},
}))
logger.info(`Successfully rehydrated stores from ${source}`)
return true
}
// Copilot workflow edit events (database has been updated, rehydrate stores)
socketInstance.on('copilot-workflow-edit', async (data) => {
logger.info(
`Copilot edited workflow ${data.workflowId} - rehydrating stores from database`
)
if (data.workflowId === urlWorkflowId) {
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
const hasPending = useOperationQueueStore
.getState()
.operations.some(
(op: any) => op.workflowId === data.workflowId && op.status !== 'confirmed'
)
if (hasPending) {
logger.info('Skipping copilot rehydration due to pending operations in queue')
return
try {
// Fetch fresh workflow state directly from API
const response = await fetch(`/api/workflows/${data.workflowId}`)
if (response.ok) {
const responseData = await response.json()
const workflowData = responseData.data
if (workflowData?.state) {
await rehydrateWorkflowStores(data.workflowId, workflowData.state, 'copilot')
}
} catch {}
try {
// Fetch fresh workflow state directly from API
const response = await fetch(`/api/workflows/${data.workflowId}`)
if (response.ok) {
const responseData = await response.json()
const workflowData = responseData.data
if (workflowData?.state) {
logger.info('Rehydrating stores with fresh workflow state from database')
// Import stores dynamically to avoid import issues
Promise.all([
import('@/stores/workflows/workflow/store'),
import('@/stores/workflows/subblock/store'),
])
.then(([{ useWorkflowStore }, { useSubBlockStore }]) => {
const workflowState = workflowData.state
// Extract subblock values from blocks
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(
([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
}
)
})
// Merge workflow store with server state (do not drop optimistic local state)
const existing = useWorkflowStore.getState()
const mergedBlocks = {
...(existing.blocks || {}),
...(workflowState.blocks || {}),
}
const edgeById = new Map<string, any>()
;(existing.edges || []).forEach((e: any) => edgeById.set(e.id, e))
;(workflowState.edges || []).forEach((e: any) => edgeById.set(e.id, e))
const mergedEdges = Array.from(edgeById.values())
useWorkflowStore.setState({
blocks: mergedBlocks,
edges: mergedEdges,
loops: workflowState.loops || existing.loops || {},
parallels: workflowState.parallels || existing.parallels || {},
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
deployedAt: workflowState.deployedAt || existing.deployedAt,
deploymentStatuses:
workflowState.deploymentStatuses || existing.deploymentStatuses || {},
hasActiveWebhook:
workflowState.hasActiveWebhook ?? existing.hasActiveWebhook ?? false,
})
// Merge subblock store values per workflow
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[data.workflowId]: {
...(state.workflowValues?.[data.workflowId] || {}),
...subblockValues,
},
},
}))
// Note: Auto-layout is already handled by the copilot backend before saving
// No need to trigger additional auto-layout here to avoid ID conflicts
logger.info('Successfully rehydrated stores from database after copilot edit')
})
.catch((error) => {
logger.error('Failed to import stores for copilot rehydration:', error)
})
}
} else {
logger.error('Failed to fetch fresh workflow state:', response.statusText)
}
} catch (error) {
logger.error('Failed to rehydrate stores after copilot edit:', error)
} else {
logger.error('Failed to fetch fresh workflow state:', response.statusText)
}
} catch (error) {
logger.error('Failed to rehydrate stores after copilot edit:', error)
}
})
@@ -479,86 +471,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
logger.debug('Operation confirmed:', data)
})
socketInstance.on('workflow-state', (workflowData) => {
socketInstance.on('workflow-state', async (workflowData) => {
logger.info('Received workflow state from server')
// Update local stores with the fresh workflow state (same logic as YAML editor)
if (workflowData?.state && workflowData.id === urlWorkflowId) {
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
const hasPending = useOperationQueueStore
.getState()
.operations.some(
(op: any) => op.workflowId === workflowData.id && op.status !== 'confirmed'
)
if (hasPending) {
logger.info(
'Skipping workflow-state rehydration due to pending operations in queue'
)
return
}
} catch {}
logger.info('Updating local stores with fresh workflow state from server')
try {
Promise.all([
import('@/stores/workflows/workflow/store'),
import('@/stores/workflows/subblock/store'),
import('@/stores/workflows/registry/store'),
])
.then(([{ useWorkflowStore }, { useSubBlockStore }]) => {
const workflowState = workflowData.state
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
})
})
const existing = useWorkflowStore.getState()
const mergedBlocks = {
...(existing.blocks || {}),
...(workflowState.blocks || {}),
}
const edgeById = new Map<string, any>()
;(existing.edges || []).forEach((e: any) => edgeById.set(e.id, e))
;(workflowState.edges || []).forEach((e: any) => edgeById.set(e.id, e))
const mergedEdges = Array.from(edgeById.values())
useWorkflowStore.setState({
blocks: mergedBlocks,
edges: mergedEdges,
loops: workflowState.loops || existing.loops || {},
parallels: workflowState.parallels || existing.parallels || {},
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
deployedAt: workflowState.deployedAt || existing.deployedAt,
deploymentStatuses:
workflowState.deploymentStatuses || existing.deploymentStatuses || {},
hasActiveWebhook:
workflowState.hasActiveWebhook ?? existing.hasActiveWebhook ?? false,
})
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[workflowData.id]: {
...(state.workflowValues?.[workflowData.id] || {}),
...subblockValues,
},
},
}))
logger.info('Merged fresh workflow state with local state')
})
.catch((error) => {
logger.error('Failed to import stores for workflow state update:', error)
})
} catch (error) {
logger.error('Failed to update local stores with workflow state:', error)
}
if (workflowData?.state) {
await rehydrateWorkflowStores(workflowData.id, workflowData.state, 'workflow-state')
}
})
@@ -597,16 +514,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
`URL workflow changed from ${currentWorkflowId} to ${urlWorkflowId}, switching rooms`
)
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
// Flush debounced updates for the old workflow before switching rooms
if (currentWorkflowId) {
useOperationQueueStore.getState().flushDebouncedForWorkflow(currentWorkflowId)
} else {
useOperationQueueStore.getState().flushAllDebounced()
}
} catch {}
// Leave current workflow first if we're in one
if (currentWorkflowId) {
logger.info(`Leaving current workflow ${currentWorkflowId} before joining ${urlWorkflowId}`)
@@ -666,7 +573,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
logger.info(`Leaving workflow: ${currentWorkflowId}`)
try {
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
useOperationQueueStore.getState().flushDebouncedForWorkflow(currentWorkflowId)
useOperationQueueStore.getState().cancelOperationsForWorkflow(currentWorkflowId)
} catch {}
socket.emit('leave-workflow')

View File

@@ -1 +0,0 @@
ALTER TABLE "workflow_schedule" DROP COLUMN "timezone";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user_rate_limits" RENAME COLUMN "user_id" TO "reference_id";--> statement-breakpoint
ALTER TABLE "user_rate_limits" DROP CONSTRAINT "user_rate_limits_user_id_user_id_fk";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "settings" ADD COLUMN "billing_usage_notifications_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" DROP COLUMN "general";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -582,6 +582,20 @@
"when": 1756768177306,
"tag": "0083_ambiguous_dreadnoughts",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1757046301281,
"tag": "0084_even_lockheed",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1757348840739,
"tag": "0085_daffy_blacklash",
"breakpoints": true
}
]
}

View File

@@ -374,8 +374,10 @@ export const settings = pgTable('settings', {
// Email preferences
emailPreferences: json('email_preferences').notNull().default('{}'),
// Keep general for future flexible settings
general: json('general').notNull().default('{}'),
// Billing usage notifications preference
billingUsageNotificationsEnabled: boolean('billing_usage_notifications_enabled')
.notNull()
.default(true),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
@@ -531,9 +533,7 @@ export const subscription = pgTable(
)
export const userRateLimits = pgTable('user_rate_limits', {
userId: text('user_id')
.primaryKey()
.references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id').primaryKey(), // Can be userId or organizationId for pooling
syncApiRequests: integer('sync_api_requests').notNull().default(0), // Sync API requests counter
asyncApiRequests: integer('async_api_requests').notNull().default(0), // Async API requests counter
windowStart: timestamp('window_start').notNull().defaultNow(),

View File

@@ -119,7 +119,7 @@ describe('RouterBlockHandler', () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
temperature: 0.5,
temperature: 0.1,
}
const expectedTargetBlocks = [
@@ -168,11 +168,11 @@ describe('RouterBlockHandler', () => {
model: 'gpt-4o',
systemPrompt: 'Generated System Prompt',
context: JSON.stringify([{ role: 'user', content: 'Choose the best option.' }]),
temperature: 0.5,
temperature: 0.1,
})
expect(result).toEqual({
content: 'Choose the best option.',
prompt: 'Choose the best option.',
model: 'mock-model',
tokens: { prompt: 100, completion: 5, total: 105 },
cost: {
@@ -233,7 +233,7 @@ describe('RouterBlockHandler', () => {
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
model: 'gpt-4o',
temperature: 0,
temperature: 0.1,
})
})

View File

@@ -51,7 +51,7 @@ export class RouterBlockHandler implements BlockHandler {
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: routerConfig.temperature,
temperature: 0.1,
apiKey: routerConfig.apiKey,
workflowId: context.workflowId,
}
@@ -102,7 +102,7 @@ export class RouterBlockHandler implements BlockHandler {
)
return {
content: inputs.prompt,
prompt: inputs.prompt,
model: result.model,
tokens: {
prompt: tokens.prompt || 0,

View File

@@ -13,7 +13,6 @@ describe('InputResolver', () => {
let resolver: InputResolver
beforeEach(() => {
// Set up a sample workflow with different types of blocks
sampleWorkflow = {
version: '1.0',
blocks: [
@@ -64,7 +63,6 @@ describe('InputResolver', () => {
},
],
connections: [
// Add connections so blocks can reference each other
{ source: 'starter-block', target: 'function-block' },
{ source: 'function-block', target: 'condition-block' },
{ source: 'condition-block', target: 'api-block' },
@@ -73,10 +71,9 @@ describe('InputResolver', () => {
loops: {},
}
// Mock execution context
mockContext = {
workflowId: 'test-workflow',
workflow: sampleWorkflow, // Add workflow reference
workflow: sampleWorkflow,
blockStates: new Map([
['starter-block', { output: { input: 'Hello World', type: 'text' } }],
['function-block', { output: { result: '42' } }], // String value as it would be in real app
@@ -92,13 +89,11 @@ describe('InputResolver', () => {
executedBlocks: new Set(['starter-block', 'function-block']),
}
// Mock environment variables
mockEnvironmentVars = {
API_KEY: 'test-api-key',
BASE_URL: 'https://api.example.com',
}
// Mock workflow variables
mockWorkflowVars = {
stringVar: {
id: 'var1',
@@ -112,28 +107,28 @@ describe('InputResolver', () => {
workflowId: 'test-workflow',
name: 'numberVar',
type: 'number',
value: '42', // Stored as string but should be converted to number
value: '42',
},
boolVar: {
id: 'var3',
workflowId: 'test-workflow',
name: 'boolVar',
type: 'boolean',
value: 'true', // Stored as string but should be converted to boolean
value: 'true',
},
objectVar: {
id: 'var4',
workflowId: 'test-workflow',
name: 'objectVar',
type: 'object',
value: '{"name":"John","age":30}', // Stored as string but should be parsed to object
value: '{"name":"John","age":30}',
},
arrayVar: {
id: 'var5',
workflowId: 'test-workflow',
name: 'arrayVar',
type: 'array',
value: '[1,2,3]', // Stored as string but should be parsed to array
value: '[1,2,3]',
},
plainVar: {
id: 'var6',
@@ -144,27 +139,21 @@ describe('InputResolver', () => {
},
}
// Create accessibility map for block references
const accessibleBlocksMap = new Map<string, Set<string>>()
// Allow all blocks to reference each other for testing
const allBlockIds = sampleWorkflow.blocks.map((b) => b.id)
// Add common test block IDs
const testBlockIds = ['test-block', 'test-block-2', 'generic-block']
const allIds = [...allBlockIds, ...testBlockIds]
// Set up accessibility for workflow blocks
sampleWorkflow.blocks.forEach((block) => {
const accessibleBlocks = new Set(allIds)
accessibleBlocksMap.set(block.id, accessibleBlocks)
})
// Set up accessibility for test blocks
testBlockIds.forEach((testId) => {
const accessibleBlocks = new Set(allIds)
accessibleBlocksMap.set(testId, accessibleBlocks)
})
// Create resolver
resolver = new InputResolver(
sampleWorkflow,
mockEnvironmentVars,
@@ -227,7 +216,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.directRef).toBe(42) // Should be converted to actual number
expect(result.directRef).toBe(42)
expect(result.interpolated).toBe('The number is 42')
})
@@ -253,7 +242,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.directRef).toBe(true) // Should be converted to boolean
expect(result.directRef).toBe(true)
expect(result.interpolated).toBe('Is it true? true')
})
@@ -277,7 +266,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.directRef).toEqual({ name: 'John', age: 30 }) // Should be parsed to object
expect(result.directRef).toEqual({ name: 'John', age: 30 })
})
it('should resolve plain text variables without quoting', () => {
@@ -318,7 +307,7 @@ describe('InputResolver', () => {
params: {
starterRef: '<starter-block.input>',
functionRef: '<function-block.result>',
nameRef: '<Start.input>', // Reference by name
nameRef: '<Start.input>',
},
},
inputs: {
@@ -333,7 +322,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.starterRef).toBe('Hello World')
expect(result.functionRef).toBe('42') // String representation
expect(result.functionRef).toBe('42')
expect(result.nameRef).toBe('Hello World') // Should resolve using block name
})
@@ -371,7 +360,7 @@ describe('InputResolver', () => {
config: {
tool: 'generic',
params: {
inactiveRef: '<condition-block.result>', // Not in activeExecutionPath
inactiveRef: '<condition-block.result>',
},
},
inputs: {
@@ -381,17 +370,13 @@ describe('InputResolver', () => {
enabled: true,
}
// Since the condition-block is not in the active execution path,
// we expect it to be treated as inactive and return an empty string
const result = resolver.resolveInputs(block, mockContext)
expect(result.inactiveRef).toBe('')
})
it('should throw an error for references to disabled blocks', () => {
// Add connection from disabled block to test block so it's accessible
sampleWorkflow.connections.push({ source: 'disabled-block', target: 'test-block' })
// Make sure disabled block stays disabled and add it to active path for validation
const disabledBlock = sampleWorkflow.blocks.find((b) => b.id === 'disabled-block')!
disabledBlock.enabled = false
mockContext.activeExecutionPath.add('disabled-block')
@@ -421,14 +406,14 @@ describe('InputResolver', () => {
it('should resolve environment variables in API key contexts', () => {
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: BlockType.API, name: 'Test API Block' }, // API block type
metadata: { id: BlockType.API, name: 'Test API Block' },
position: { x: 0, y: 0 },
config: {
tool: 'api',
params: {
apiKey: '{{API_KEY}}',
url: 'https://example.com?key={{API_KEY}}',
regularParam: 'Base URL is: {{BASE_URL}}', // Should not be resolved in regular params
regularParam: 'Base URL is: {{BASE_URL}}',
},
},
inputs: {
@@ -444,7 +429,7 @@ describe('InputResolver', () => {
expect(result.apiKey).toBe('test-api-key')
expect(result.url).toBe('https://example.com?key=test-api-key')
expect(result.regularParam).toBe('Base URL is: {{BASE_URL}}') // Should not be resolved
expect(result.regularParam).toBe('Base URL is: {{BASE_URL}}')
})
it('should resolve explicit environment variables', () => {
@@ -455,7 +440,7 @@ describe('InputResolver', () => {
config: {
tool: 'generic',
params: {
explicitEnv: '{{BASE_URL}}', // Full string is just an env var
explicitEnv: '{{BASE_URL}}',
},
},
inputs: {
@@ -490,7 +475,6 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
// Environment variable should not be resolved in regular contexts
expect(result.regularParam).toBe('Value with {{API_KEY}} embedded')
})
})
@@ -538,8 +522,8 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.tableParam[0].cells.Value).toBe('Hello') // string var
expect(result.tableParam[1].cells.Value).toBe(42) // number var - correctly typed
expect(result.tableParam[0].cells.Value).toBe('Hello')
expect(result.tableParam[1].cells.Value).toBe(42)
expect(result.tableParam[2].cells.Value).toBe('Raw text without quotes') // plain var
})
@@ -579,7 +563,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
expect(result.tableParam[0].cells.Value).toBe('Hello World')
expect(result.tableParam[1].cells.Value).toBe('42') // Result values come as strings
expect(result.tableParam[1].cells.Value).toBe('42')
})
it('should handle interpolated variable references in table cells', () => {
@@ -635,9 +619,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
// String should be quoted in code context
expect(result.code).toContain('const name = "Hello";')
// Number should not be quoted
expect(result.code).toContain('const num = 42;')
})
@@ -661,7 +643,6 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
// Body should be parsed into an object
expect(result.body).toEqual({
name: 'Hello',
value: 42,
@@ -688,7 +669,6 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
// Conditions should be passed through without parsing for condition blocks
expect(result.conditions).toBe('<start.input> === "Hello World"')
})
})
@@ -739,7 +719,7 @@ describe('InputResolver', () => {
config: {
tool: BlockType.FUNCTION,
params: {
item: '<loop.currentItem>', // Direct reference, not wrapped in quotes
item: '<loop.currentItem>',
},
},
inputs: {},
@@ -801,7 +781,7 @@ describe('InputResolver', () => {
config: {
tool: BlockType.FUNCTION,
params: {
index: '<loop.index>', // Direct reference, not wrapped in quotes
index: '<loop.index>',
},
},
inputs: {},
@@ -1610,7 +1590,7 @@ describe('InputResolver', () => {
}
expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow(
/Available connected blocks:.*Agent Block.*agent-1.*start/
/Available connected blocks:.*Agent Block.*Start/
)
})
@@ -2387,4 +2367,212 @@ describe('InputResolver', () => {
expect(result3).not.toHaveProperty('content')
})
})
describe('Variable Reference Validation', () => {
it('should allow block references without dots like <start>', () => {
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: 'generic', name: 'Test Block' },
position: { x: 0, y: 0 },
config: {
tool: 'generic',
params: {
content: 'Value from <start> block',
},
},
inputs: {
content: 'string',
},
outputs: {},
enabled: true,
}
const result = resolver.resolveInputs(block, mockContext)
expect(result.content).not.toBe('Value from <start> block')
})
it('should allow other block references without dots', () => {
const testAccessibility = new Map<string, Set<string>>()
const allIds = [
'starter-block',
'function-block',
'condition-block',
'api-block',
'testblock',
]
allIds.forEach((id) => {
testAccessibility.set(id, new Set(allIds))
})
testAccessibility.set('test-block', new Set(allIds))
const testResolver = new InputResolver(
sampleWorkflow,
mockEnvironmentVars,
mockWorkflowVars,
undefined,
testAccessibility
)
const extendedWorkflow = {
...sampleWorkflow,
blocks: [
...sampleWorkflow.blocks,
{
id: 'testblock',
metadata: { id: 'generic', name: 'TestBlock' },
position: { x: 500, y: 100 },
config: { tool: 'generic', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
}
const extendedContext = {
...mockContext,
workflow: extendedWorkflow,
blockStates: new Map([
...mockContext.blockStates,
['testblock', { output: { result: 'test result' } }],
]),
activeExecutionPath: new Set([...mockContext.activeExecutionPath, 'testblock']),
}
const testResolverWithExtended = new InputResolver(
extendedWorkflow,
mockEnvironmentVars,
mockWorkflowVars,
undefined,
testAccessibility
)
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: 'generic', name: 'Test Block' },
position: { x: 0, y: 0 },
config: {
tool: 'generic',
params: {
content: 'Value from <testblock> is here',
},
},
inputs: {
content: 'string',
},
outputs: {},
enabled: true,
}
expect(() => testResolverWithExtended.resolveInputs(block, extendedContext)).not.toThrow()
})
it('should reject operator expressions that look like comparisons', () => {
const block: SerializedBlock = {
id: 'condition-block',
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
position: { x: 0, y: 0 },
config: {
tool: 'condition',
params: {
conditions: 'x < 5 && 8 > b',
},
},
inputs: {
conditions: 'string',
},
outputs: {},
enabled: true,
}
const result = resolver.resolveInputs(block, mockContext)
expect(result.conditions).toBe('x < 5 && 8 > b')
})
it('should still allow regular dotted references', () => {
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: 'generic', name: 'Test Block' },
position: { x: 0, y: 0 },
config: {
tool: 'generic',
params: {
starterInput: '<start.input>',
functionResult: '<function-block.result>',
variableRef: '<variable.stringVar>',
},
},
inputs: {
starterInput: 'string',
functionResult: 'string',
variableRef: 'string',
},
outputs: {},
enabled: true,
}
const result = resolver.resolveInputs(block, mockContext)
expect(result.starterInput).toBe('Hello World')
expect(result.functionResult).toBe('42')
expect(result.variableRef).toBe('Hello')
})
it('should handle complex expressions with both valid references and operators', () => {
const block: SerializedBlock = {
id: 'condition-block',
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
position: { x: 0, y: 0 },
config: {
tool: 'condition',
params: {
conditions:
'<start.input> === "Hello" && x < 5 && 8 > y && <function-block.result> !== null',
},
},
inputs: {
conditions: 'string',
},
outputs: {},
enabled: true,
}
const result = resolver.resolveInputs(block, mockContext)
expect(result.conditions).toBe(
'<start.input> === "Hello" && x < 5 && 8 > y && <function-block.result> !== null'
)
})
it('should reject numeric patterns that look like arithmetic', () => {
const block: SerializedBlock = {
id: 'test-block',
metadata: { id: 'generic', name: 'Test Block' },
position: { x: 0, y: 0 },
config: {
tool: 'generic',
params: {
content1: 'value < 5 is true',
content2: 'check 8 > x condition',
content3: 'result = 10 + 5',
},
},
inputs: {
content1: 'string',
content2: 'string',
content3: 'string',
},
outputs: {},
enabled: true,
}
const result = resolver.resolveInputs(block, mockContext)
expect(result.content1).toBe('value < 5 is true')
expect(result.content2).toBe('check 8 > x condition')
expect(result.content3).toBe('result = 10 + 5')
})
})
})

View File

@@ -463,10 +463,18 @@ export class InputResolver {
const blockMatches = value.match(/<([^>]+)>/g)
if (!blockMatches) return value
// If we're in an API block body, check each match to see if it looks like XML rather than a reference
// Filter out patterns that are clearly not variable references (e.g., comparison operators)
const validBlockMatches = blockMatches.filter((match) => this.isValidVariableReference(match))
// If no valid matches found after filtering, return original value
if (validBlockMatches.length === 0) {
return value
}
// If we're in an API block body, check each valid match to see if it looks like XML rather than a reference
if (
currentBlock.metadata?.id === 'api' &&
blockMatches.some((match) => {
validBlockMatches.some((match) => {
const innerContent = match.slice(1, -1)
// Patterns that suggest this is XML, not a block reference:
return (
@@ -490,7 +498,7 @@ export class InputResolver {
value.includes('}') &&
value.includes('`')
for (const match of blockMatches) {
for (const match of validBlockMatches) {
// Skip variables - they've already been processed
if (match.startsWith('<variable.')) {
continue
@@ -814,6 +822,57 @@ export class InputResolver {
return resolvedValue
}
/**
* Validates if a match with < and > is actually a variable reference.
* Valid variable references must:
* - Have no space after the opening <
* - Contain a dot (.)
* - Have no spaces until the closing >
* - Not be comparison operators or HTML tags
*
* @param match - The matched string including < and >
* @returns Whether this is a valid variable reference
*/
private isValidVariableReference(match: string): boolean {
const innerContent = match.slice(1, -1)
if (innerContent.startsWith(' ')) {
return false
}
if (innerContent.match(/^\s*[<>=!]+\s*$/) || innerContent.match(/\s[<>=!]+\s/)) {
return false
}
if (innerContent.match(/^[<>=!]+\s/)) {
return false
}
if (innerContent.includes('.')) {
const dotIndex = innerContent.indexOf('.')
const beforeDot = innerContent.substring(0, dotIndex)
const afterDot = innerContent.substring(dotIndex + 1)
if (afterDot.includes(' ')) {
return false
}
if (beforeDot.match(/[+*/=<>!]/) || afterDot.match(/[+\-*/=<>!]/)) {
return false
}
} else {
if (
innerContent.match(/[+\-*/=<>!]/) ||
innerContent.match(/^\d/) ||
innerContent.match(/\s\d/)
) {
return false
}
}
return true
}
/**
* Determines if a string contains a properly formatted environment variable reference.
* Valid references are either:
@@ -1145,6 +1204,24 @@ export class InputResolver {
return [...new Set(names)] // Remove duplicates
}
/**
* Gets user-friendly block names for error messages.
* Only returns the actual block names that users see in the UI.
*/
private getAccessibleBlockNamesForError(currentBlockId: string): string[] {
const accessibleBlockIds = this.getAccessibleBlocks(currentBlockId)
const names: string[] = []
for (const blockId of accessibleBlockIds) {
const block = this.blockById.get(blockId)
if (block?.metadata?.name) {
names.push(block.metadata.name)
}
}
return [...new Set(names)] // Remove duplicates
}
/**
* Checks if a block reference could potentially be valid without throwing errors.
* Used to filter out non-block patterns like <test> from block reference resolution.
@@ -1197,7 +1274,7 @@ export class InputResolver {
}
if (!sourceBlock) {
const accessibleNames = this.getAccessibleBlockNames(currentBlockId)
const accessibleNames = this.getAccessibleBlockNamesForError(currentBlockId)
return {
isValid: false,
errorMessage: `Block "${blockRef}" was not found. Available connected blocks: ${accessibleNames.join(', ')}`,
@@ -1207,7 +1284,7 @@ export class InputResolver {
// Check if block is accessible (connected)
const accessibleBlocks = this.getAccessibleBlocks(currentBlockId)
if (!accessibleBlocks.has(sourceBlock.id)) {
const accessibleNames = this.getAccessibleBlockNames(currentBlockId)
const accessibleNames = this.getAccessibleBlockNamesForError(currentBlockId)
return {
isValid: false,
errorMessage: `Block "${blockRef}" is not connected to this block. Available connected blocks: ${accessibleNames.join(', ')}`,

View File

@@ -1229,6 +1229,17 @@ export const auth = betterAuth({
error,
})
}
// Send welcome email for Pro and Team plans
try {
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
await sendPlanWelcomeEmail(subscription)
} catch (error) {
logger.error('[onSubscriptionComplete] Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
})
}
},
onSubscriptionUpdate: async ({
subscription,

View File

@@ -35,7 +35,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
: 0
return {
percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100),
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
isWarning: false,
isExceeded: false,
currentUsage,
@@ -69,7 +69,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
)
// Calculate percentage used
const percentUsed = Math.min(Math.floor((currentUsage / limit) * 100), 100)
const percentUsed = Math.min((currentUsage / limit) * 100, 100)
// Check org-level cap for team/enterprise pooled usage
let isExceeded = currentUsage >= limit

View File

@@ -267,7 +267,7 @@ export async function getSimplifiedBillingSummary(
}
const overageAmount = Math.max(0, currentUsage - basePrice)
const percentUsed = usageData.limit > 0 ? Math.round((currentUsage / usageData.limit) * 100) : 0
const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0
// Calculate days remaining in billing period
const daysRemaining = usageData.billingPeriodEnd

View File

@@ -7,10 +7,11 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import { env } from '@/lib/env'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { member, subscription, user, userStats } from '@/db/schema'
const logger = createLogger('SubscriptionCore')
@@ -74,7 +75,6 @@ export async function getHighestPrioritySubscription(userId: string) {
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
// In development, enable Pro features for easier testing
if (!isProd) {
return true
}
@@ -155,7 +155,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
const subscription = await getHighestPrioritySubscription(userId)
// Calculate usage limit
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
@@ -283,3 +282,54 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
}
}
}
/**
* Send welcome email for Pro and Team plan subscriptions
*/
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
try {
const subPlan = subscription.plan
if (subPlan === 'pro' || subPlan === 'team') {
const userId = subscription.referenceId
const users = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (users.length > 0 && users[0].email) {
const { getEmailSubject, renderPlanWelcomeEmail } = await import(
'@/components/emails/render-email'
)
const { sendEmail } = await import('@/lib/email/mailer')
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const html = await renderPlanWelcomeEmail({
planName: subPlan === 'pro' ? 'Pro' : 'Team',
userName: users[0].name || undefined,
loginLink: `${baseUrl}/login`,
})
await sendEmail({
to: users[0].email,
subject: getEmailSubject(subPlan === 'pro' ? 'plan-welcome-pro' : 'plan-welcome-team'),
html,
emailType: 'updates',
})
logger.info('Plan welcome email sent successfully', {
userId,
email: users[0].email,
plan: subPlan,
})
}
}
} catch (error) {
logger.error('Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
plan: subscription.plan,
})
throw error
}
}

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
canEditUsageLimit,
@@ -6,9 +7,12 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { sendEmail } from '@/lib/email/mailer'
import { getEmailPreferences } from '@/lib/email/unsubscribe'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, organization, user, userStats } from '@/db/schema'
import { member, organization, settings, user, userStats } from '@/db/schema'
const logger = createLogger('UsageManagement')
@@ -82,7 +86,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
}
}
const percentUsed = limit > 0 ? Math.min(Math.floor((currentUsage / limit) * 100), 100) : 0
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
const isWarning = percentUsed >= 80
const isExceeded = currentUsage >= limit
@@ -531,3 +535,89 @@ export async function calculateBillingProjection(userId: string): Promise<Billin
throw error
}
}
/**
* Send usage threshold notification when crossing from <80% to ≥80%.
* - Skips when billing is disabled.
* - Respects user-level notifications toggle and unsubscribe preferences.
* - For organization plans, emails owners/admins who have notifications enabled.
*/
export async function maybeSendUsageThresholdEmail(params: {
scope: 'user' | 'organization'
planName: string
percentBefore: number
percentAfter: number
userId?: string
userEmail?: string
userName?: string
organizationId?: string
currentUsageAfter: number
limit: number
}): Promise<void> {
try {
if (!isBillingEnabled) return
// Only on upward crossing to >= 80%
if (!(params.percentBefore < 80 && params.percentAfter >= 80)) return
if (params.limit <= 0 || params.currentUsageAfter <= 0) return
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const ctaLink = `${baseUrl}/workspace?billing=usage`
const sendTo = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderUsageThresholdEmail({
userName: name,
planName: params.planName,
percentUsed: Math.min(100, Math.round(params.percentAfter)),
currentUsage: params.currentUsageAfter,
limit: params.limit,
ctaLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('usage-threshold'),
html,
emailType: 'notifications',
})
}
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendTo(params.userEmail, params.userName)
} else if (params.scope === 'organization' && params.organizationId) {
const admins = await db
.select({
email: user.email,
name: user.name,
enabled: settings.billingUsageNotificationsEnabled,
role: member.role,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(settings, eq(settings.userId, member.userId))
.where(eq(member.organizationId, params.organizationId))
for (const a of admins) {
const isAdmin = a.role === 'owner' || a.role === 'admin'
if (!isAdmin) continue
if (a.enabled === false) continue
if (!a.email) continue
await sendTo(a.email, a.name || undefined)
}
}
} catch (error) {
logger.error('Failed to send usage threshold email', {
scope: params.scope,
userId: params.userId,
organizationId: params.organizationId,
error,
})
}
}

View File

@@ -13,6 +13,7 @@ export {
isEnterprisePlan as hasEnterprisePlan,
isProPlan as hasProPlan,
isTeamPlan as hasTeamPlan,
sendPlanWelcomeEmail,
} from '@/lib/billing/core/subscription'
export * from '@/lib/billing/core/usage'
export {

View File

@@ -95,25 +95,6 @@ export class BuildWorkflowClientTool extends BaseClientTool {
// Populate diff preview immediately (without marking complete yet)
try {
const diffStore = useWorkflowDiffStore.getState()
// Send early stats upsert with the triggering user message id if available
try {
const { useCopilotStore } = await import('@/stores/copilot/store')
const { currentChat, currentUserMessageId, agentDepth, agentPrefetch } =
useCopilotStore.getState() as any
if (currentChat?.id && currentUserMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: currentChat.id,
messageId: currentUserMessageId,
depth: agentDepth,
maxEnabled: agentDepth >= 2 && !agentPrefetch,
diffCreated: true,
}),
}).catch(() => {})
}
} catch {}
await diffStore.setProposedChanges(result.yamlContent)
logger.info('diff proposed changes set')
} catch (e) {

View File

@@ -151,25 +151,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
try {
if (!this.hasAppliedDiff) {
const diffStore = useWorkflowDiffStore.getState()
// Send early stats upsert with the triggering user message id if available
try {
const { useCopilotStore } = await import('@/stores/copilot/store')
const { currentChat, currentUserMessageId, agentDepth, agentPrefetch } =
useCopilotStore.getState() as any
if (currentChat?.id && currentUserMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: currentChat.id,
messageId: currentUserMessageId,
depth: agentDepth,
maxEnabled: agentDepth >= 2 && !agentPrefetch,
diffCreated: true,
}),
}).catch(() => {})
}
} catch {}
await diffStore.setProposedChanges(result.yamlContent)
logger.info('diff proposed changes set for edit_workflow')
this.hasAppliedDiff = true

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