mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784992f347 | ||
|
|
5218dd41b9 | ||
|
|
07e70409c7 | ||
|
|
07ba17422b | ||
|
|
d45324bb83 | ||
|
|
ced64129da | ||
|
|
1e14743391 | ||
|
|
a0bb754c8c | ||
|
|
851031239d | ||
|
|
3811b509ef | ||
|
|
abb835d22d | ||
|
|
f2a046ff24 | ||
|
|
bd6d4a91a3 | ||
|
|
21beca8fd5 | ||
|
|
0a86eda853 | ||
|
|
60a061e38a | ||
|
|
ab71fcfc49 | ||
|
|
864622c1dc | ||
|
|
8668622d66 | ||
|
|
53dd277cfe | ||
|
|
0e8e8c7a47 | ||
|
|
47da5eb6e8 | ||
|
|
37dcde2afc | ||
|
|
e31627c7c2 | ||
|
|
57c98d86ba | ||
|
|
0f7dfe084a | ||
|
|
afc1632830 | ||
|
|
56eee2c2d2 | ||
|
|
fc558a8eef | ||
|
|
c68cadfb84 | ||
|
|
95d93a2532 | ||
|
|
59b2023124 | ||
|
|
a672f17136 | ||
|
|
1de59668e4 | ||
|
|
26243b99e8 | ||
|
|
fce1423d05 | ||
|
|
3656d3d7ad | ||
|
|
581929bc01 | ||
|
|
11d8188415 | ||
|
|
36c98d18e9 |
@@ -159,7 +159,7 @@ bun run dev:sockets
|
||||
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
|
||||
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
- Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
After a router makes a decision, you can access its outputs:
|
||||
|
||||
- **`<router.content>`**: Summary of the routing decision made
|
||||
- **`<router.prompt>`**: Summary of the routing prompt used
|
||||
- **`<router.selected_path>`**: Details of the chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics from the LLM
|
||||
- **`<router.model>`**: The model used for decision-making
|
||||
@@ -182,7 +182,7 @@ Confidence Threshold: 0.7 // Minimum confidence for routing
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>router.content</strong>: Summary of routing decision
|
||||
<strong>router.prompt</strong>: Summary of routing prompt used
|
||||
</li>
|
||||
<li>
|
||||
<strong>router.selected_path</strong>: Details of chosen destination
|
||||
|
||||
@@ -91,4 +91,31 @@ Copilot is your in-editor assistant that helps you build, understand, and improv
|
||||
>
|
||||
<div className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
</Cards>
|
||||
|
||||
## Billing and Cost Calculation
|
||||
|
||||
### How Costs Are Calculated
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM:
|
||||
|
||||
- **Input tokens**: billed at the provider's base rate (**at-cost**)
|
||||
- **Output tokens**: billed at **1.5×** the provider's base output rate
|
||||
|
||||
```javascript
|
||||
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
|
||||
```
|
||||
|
||||
| Component | Rate Applied |
|
||||
|----------|----------------------|
|
||||
| Input | inputPrice |
|
||||
| Output | outputPrice × 1.5 |
|
||||
|
||||
<Callout type="warning">
|
||||
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See <a href="/execution/advanced#cost-calculation">Logging and Cost Calculation</a> for background and examples.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `projectId` | string | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. |
|
||||
| `projectId` | string | No | Jira project ID \(optional; not required to retrieve a single issue\). |
|
||||
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -585,6 +585,7 @@ export async function POST(req: NextRequest) {
|
||||
const useE2B =
|
||||
e2bEnabled &&
|
||||
!useLocalVM &&
|
||||
!isCustomTool &&
|
||||
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
|
||||
|
||||
if (useE2B) {
|
||||
|
||||
@@ -339,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
@@ -352,7 +352,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -85,7 +85,8 @@ export async function POST(request: Request) {
|
||||
|
||||
logger.info(`Fetching all Discord channels for server: ${serverId}`)
|
||||
|
||||
// Fetch all channels from Discord API
|
||||
// Listing guild channels with a bot token is allowed if the bot is in the guild.
|
||||
// Keep the request, but if unauthorized, return an empty list so the selector doesn't hard fail.
|
||||
const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}/channels`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -95,20 +96,14 @@ export async function POST(request: Request) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Discord API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch channels (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch channels: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
logger.warn(
|
||||
'Discord API returned non-OK for channels; returning empty list to avoid UX break',
|
||||
{
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
)
|
||||
return NextResponse.json({ channels: [] })
|
||||
}
|
||||
|
||||
const channels = (await response.json()) as DiscordChannel[]
|
||||
|
||||
@@ -64,46 +64,14 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise, fetch all servers the bot is in
|
||||
logger.info('Fetching all Discord servers')
|
||||
|
||||
const response = await fetch('https://discord.com/api/v10/users/@me/guilds', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Discord API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch servers (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch servers: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const servers = (await response.json()) as DiscordServer[]
|
||||
logger.info(`Successfully fetched ${servers.length} servers`)
|
||||
|
||||
return NextResponse.json({
|
||||
servers: servers.map((server: DiscordServer) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
icon: server.icon
|
||||
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`
|
||||
: null,
|
||||
})),
|
||||
})
|
||||
// Listing guilds via REST requires a user OAuth2 access token with the 'guilds' scope.
|
||||
// A bot token cannot call /users/@me/guilds and will return 401.
|
||||
// Since this selector only has a bot token, return an empty list instead of erroring
|
||||
// and let users provide a Server ID in advanced mode.
|
||||
logger.info(
|
||||
'Skipping guild listing: bot token cannot list /users/@me/guilds; returning empty list'
|
||||
)
|
||||
return NextResponse.json({ servers: [] })
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -6,17 +6,32 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('JiraIssuesAPI')
|
||||
|
||||
// Helper functions
|
||||
const createErrorResponse = async (response: Response, defaultMessage: string) => {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.message || errorData.errorMessages?.[0] || defaultMessage
|
||||
} catch {
|
||||
return defaultMessage
|
||||
}
|
||||
}
|
||||
|
||||
const validateRequiredParams = (domain: string | null, accessToken: string | null) => {
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
const validationError = validateRequiredParams(domain || null, accessToken || null)
|
||||
if (validationError) return validationError
|
||||
|
||||
if (issueKeys.length === 0) {
|
||||
logger.info('No issue keys provided, returning empty result')
|
||||
@@ -24,7 +39,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
|
||||
|
||||
// Build the URL using cloudId for Jira API
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch`
|
||||
@@ -53,47 +68,24 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', JSON.stringify(errorData, null, 2))
|
||||
errorMessage = errorData.message || `Failed to fetch Jira issues (${response.status})`
|
||||
} catch (e) {
|
||||
logger.error('Could not parse error response as JSON:', e)
|
||||
|
||||
try {
|
||||
const _text = await response.text()
|
||||
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
|
||||
} catch (_textError) {
|
||||
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch Jira issues (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const issues = (data.issues || []).map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.fields.summary,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
modifiedTime: issue.fields.updated,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
|
||||
if (data.issues && data.issues.length > 0) {
|
||||
data.issues.slice(0, 3).forEach((issue: any) => {
|
||||
logger.info(`- ${issue.key}: ${issue.fields.summary}`)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
issues: data.issues
|
||||
? data.issues.map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.fields.summary,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
modifiedTime: issue.fields.updated,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
: [],
|
||||
cloudId, // Return the cloudId so it can be cached
|
||||
})
|
||||
return NextResponse.json({ issues, cloudId })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issues:', error)
|
||||
return NextResponse.json(
|
||||
@@ -111,83 +103,79 @@ export async function GET(request: Request) {
|
||||
const providedCloudId = url.searchParams.get('cloudId')
|
||||
const query = url.searchParams.get('query') || ''
|
||||
const projectId = url.searchParams.get('projectId') || ''
|
||||
const manualProjectId = url.searchParams.get('manualProjectId') || ''
|
||||
const all = url.searchParams.get('all')?.toLowerCase() === 'true'
|
||||
const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10)
|
||||
const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
logger.info('Using cloud ID:', cloudId)
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Only add query if it exists
|
||||
if (query) {
|
||||
params.append('query', query)
|
||||
}
|
||||
const validationError = validateRequiredParams(domain || null, accessToken || null)
|
||||
if (validationError) return validationError
|
||||
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
|
||||
let data: any
|
||||
|
||||
if (query) {
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
|
||||
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
|
||||
const params = new URLSearchParams({ query })
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params}`
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
logger.info('Response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.message || `Failed to fetch issue suggestions (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
|
||||
}
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch issue suggestions (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
data = await response.json()
|
||||
} else if (projectId) {
|
||||
// When no query, list latest issues for the selected project using Search API
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
|
||||
searchParams.append('maxResults', '25')
|
||||
searchParams.append('fields', 'summary,key')
|
||||
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
|
||||
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira Search API error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
} else if (projectId || manualProjectId) {
|
||||
const SAFETY_CAP = 1000
|
||||
const PAGE_SIZE = 100
|
||||
const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP)
|
||||
const projectKey = (projectId || manualProjectId).trim()
|
||||
|
||||
const buildSearchUrl = (startAt: number) => {
|
||||
const params = new URLSearchParams({
|
||||
jql: `project=${projectKey} ORDER BY updated DESC`,
|
||||
maxResults: String(Math.min(PAGE_SIZE, target)),
|
||||
startAt: String(startAt),
|
||||
fields: 'summary,key,updated',
|
||||
})
|
||||
return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}`
|
||||
}
|
||||
const searchData = await response.json()
|
||||
const issues = (searchData.issues || []).map((it: any) => ({
|
||||
|
||||
let startAt = 0
|
||||
let collected: any[] = []
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const response = await fetch(buildSearchUrl(startAt), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await createErrorResponse(
|
||||
response,
|
||||
`Failed to fetch issues (${response.status})`
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const page = await response.json()
|
||||
const issues = page.issues || []
|
||||
total = page.total || issues.length
|
||||
collected = collected.concat(issues)
|
||||
startAt += PAGE_SIZE
|
||||
} while (all && collected.length < Math.min(total, target))
|
||||
|
||||
const issues = collected.slice(0, target).map((it: any) => ({
|
||||
key: it.key,
|
||||
summary: it.fields?.summary || it.key,
|
||||
}))
|
||||
@@ -196,10 +184,7 @@ export async function GET(request: Request) {
|
||||
data = { sections: [], cloudId }
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
cloudId, // Return the cloudId so it can be cached
|
||||
})
|
||||
return NextResponse.json({ ...data, cloudId })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issue suggestions:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -42,10 +42,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!issueType) {
|
||||
logger.error('Missing issue type in request')
|
||||
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
|
||||
}
|
||||
const normalizedIssueType = issueType || 'Task'
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
|
||||
@@ -62,7 +59,7 @@ export async function POST(request: Request) {
|
||||
id: projectId,
|
||||
},
|
||||
issuetype: {
|
||||
name: issueType,
|
||||
name: normalizedIssueType,
|
||||
},
|
||||
summary: summary,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -11,9 +12,8 @@ import {
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { db } from '@/db'
|
||||
import { subscription, webhook, workflow } from '@/db/schema'
|
||||
import { webhook, workflow } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
import type { SubscriptionPlan } from '@/services/queue/types'
|
||||
|
||||
const logger = createLogger('WebhookTriggerAPI')
|
||||
|
||||
@@ -248,20 +248,14 @@ export async function POST(
|
||||
|
||||
// --- PHASE 3: Rate limiting for webhook execution ---
|
||||
try {
|
||||
// Get user subscription for rate limiting
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, foundWorkflow.userId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
// Get user subscription for rate limiting (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(foundWorkflow.userId)
|
||||
|
||||
// Check async rate limits (webhooks are processed asynchronously)
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
foundWorkflow.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'webhook',
|
||||
true // isAsync = true for webhook execution
|
||||
)
|
||||
|
||||
@@ -195,14 +195,31 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// Process blocks
|
||||
blocks.forEach((block) => {
|
||||
const parentId = block.parentId || null
|
||||
const extent = block.extent || null
|
||||
const blockData = {
|
||||
...(block.data || {}),
|
||||
...(parentId && { parentId }),
|
||||
...(extent && { extent }),
|
||||
}
|
||||
|
||||
blocksMap[block.id] = {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: { x: Number(block.positionX), y: Number(block.positionY) },
|
||||
data: block.data,
|
||||
data: blockData,
|
||||
enabled: block.enabled,
|
||||
subBlocks: block.subBlocks || {},
|
||||
// Preserve execution-relevant flags so serializer behavior matches manual runs
|
||||
isWide: block.isWide ?? false,
|
||||
advancedMode: block.advancedMode ?? false,
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
outputs: block.outputs || {},
|
||||
horizontalHandles: block.horizontalHandles ?? true,
|
||||
height: Number(block.height || 0),
|
||||
parentId,
|
||||
extent,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
@@ -97,7 +98,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
variables: source.variables || {},
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
@@ -46,6 +46,11 @@ describe('Workflow Execution API Route', () => {
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
checkRateLimitWithSubscription: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
})),
|
||||
RateLimitError: class RateLimitError extends Error {
|
||||
constructor(
|
||||
@@ -66,6 +71,13 @@ describe('Workflow Execution API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn().mockResolvedValue({
|
||||
plan: 'free',
|
||||
referenceId: 'user-id',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
subscription: {
|
||||
plan: 'plan',
|
||||
|
||||
@@ -5,6 +5,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
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
}))
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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) || ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function WorkflowItem({
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (dragStartedRef.current || isEditing) {
|
||||
if (isDragging || isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -154,7 +154,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
|
||||
// Construct parameters based on operation
|
||||
const baseParams = {
|
||||
accessToken: credential,
|
||||
credential,
|
||||
...rest,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }> }[]
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal file
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal 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
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal file
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal 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
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "workflow_schedule" DROP COLUMN "timezone";
|
||||
2
apps/sim/db/migrations/0084_even_lockheed.sql
Normal file
2
apps/sim/db/migrations/0084_even_lockheed.sql
Normal 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";
|
||||
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal file
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal 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";
|
||||
6024
apps/sim/db/migrations/meta/0084_snapshot.json
Normal file
6024
apps/sim/db/migrations/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(', ')}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user