mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c47cf4161 | ||
|
|
db1cf8a6db | ||
|
|
c6912095f7 | ||
|
|
154d9eef6a | ||
|
|
c2ded1f3e1 | ||
|
|
ff43528d35 | ||
|
|
692ba69864 | ||
|
|
cb7ce8659b | ||
|
|
5caef3a37d | ||
|
|
a6888da124 | ||
|
|
07b0597f4f | ||
|
|
71e2994f9d | ||
|
|
9973b2c165 | ||
|
|
d9e5777538 | ||
|
|
dd74267313 | ||
|
|
1db72dc823 | ||
|
|
da707fa491 | ||
|
|
9ffaf305bd | ||
|
|
26e6286fda | ||
|
|
c795fc83aa | ||
|
|
cea42f5135 | ||
|
|
6fd6f921dc | ||
|
|
7530fb9a4e | ||
|
|
9a5b035822 | ||
|
|
0c0b6bf967 | ||
|
|
5d74db53ff | ||
|
|
b39bdfd55e | ||
|
|
6b185be9a4 | ||
|
|
214a0358b6 | ||
|
|
bbb5e53e43 | ||
|
|
79e932fed9 | ||
|
|
9ad36c0e34 | ||
|
|
2771c688ff | ||
|
|
d58ceb4bce | ||
|
|
69773c3174 |
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Trigger.dev Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Trigger.dev Deploy
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: trigger-deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Deploy to Staging
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy -e staging
|
||||
|
||||
- name: Deploy to Production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy
|
||||
|
||||
@@ -160,6 +160,7 @@ 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
|
||||
- Host Sim on a publicly available DNS and set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to that value ([ngrok](https://ngrok.com/))
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ Copilot is your in-editor assistant that helps you build, understand, and improv
|
||||
- **Edit**: Make changes to blocks, connections, and settings when you approve
|
||||
|
||||
<Callout type="info">
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot) and set `COPILOT_API_KEY` in your environment.
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
|
||||
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
3. Host Sim on a publicly available DNS and set `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` to that value (e.g., using ngrok)
|
||||
</Callout>
|
||||
|
||||
## Modes
|
||||
|
||||
@@ -115,8 +115,7 @@ Read data from a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Excel spreadsheet data and metadata |
|
||||
| `data` | object | Range data from the spreadsheet |
|
||||
|
||||
### `microsoft_excel_write`
|
||||
|
||||
@@ -136,8 +135,11 @@ Write data to a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Write operation results and metadata |
|
||||
| `updatedRange` | string | The range that was updated |
|
||||
| `updatedRows` | number | Number of rows that were updated |
|
||||
| `updatedColumns` | number | Number of columns that were updated |
|
||||
| `updatedCells` | number | Number of cells that were updated |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
### `microsoft_excel_table_add`
|
||||
|
||||
@@ -155,8 +157,9 @@ Add new rows to a Microsoft Excel table
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Table add operation results and metadata |
|
||||
| `index` | number | Index of the first row that was added |
|
||||
| `values` | array | Array of rows that were added to the table |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | object | The row data if found, null if not found |
|
||||
| `results` | array | Array containing the row data if found, empty array if not found |
|
||||
|
||||
### `supabase_update`
|
||||
|
||||
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of deleted records |
|
||||
|
||||
### `supabase_upsert`
|
||||
|
||||
Insert or update data in a Supabase table (upsert operation)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to upsert data into |
|
||||
| `data` | any | Yes | The data to upsert \(insert or update\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of upserted records |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -84,14 +84,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if the access token is valid
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`[${requestId}] No access token available for credential`)
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Refresh the token if needed
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
return NextResponse.json({ accessToken }, { status: 200 })
|
||||
} catch (_error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshOAuthToken } from '@/lib/oauth/oauth'
|
||||
@@ -70,7 +70,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
})
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
|
||||
.orderBy(account.createdAt)
|
||||
// Always use the most recently updated credential for this provider
|
||||
.orderBy(desc(account.updatedAt))
|
||||
.limit(1)
|
||||
|
||||
if (connections.length === 0) {
|
||||
@@ -80,19 +81,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
|
||||
const credential = connections[0]
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`Access token is null for user ${userId}, provider ${providerId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the token is expired and needs refreshing
|
||||
// Determine whether we should refresh: missing token OR expired token
|
||||
const now = new Date()
|
||||
const tokenExpiry = credential.accessTokenExpiresAt
|
||||
// Only refresh if we have an expiration time AND it's expired AND we have a refresh token
|
||||
const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken
|
||||
const shouldAttemptRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
|
||||
|
||||
if (needsRefresh) {
|
||||
if (shouldAttemptRefresh) {
|
||||
logger.info(
|
||||
`Access token expired for user ${userId}, provider ${providerId}. Attempting to refresh.`
|
||||
)
|
||||
@@ -141,6 +136,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(
|
||||
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
|
||||
return credential.accessToken
|
||||
}
|
||||
@@ -164,19 +166,21 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
const accessToken = credential.accessToken
|
||||
|
||||
if (needsRefresh && credential.refreshToken) {
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
const refreshedToken = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!
|
||||
)
|
||||
|
||||
if (!refreshedToken) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, {
|
||||
@@ -217,6 +221,7 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
} else if (!accessToken) {
|
||||
// We have no access token and either no refresh token or not eligible to refresh
|
||||
logger.error(`[${requestId}] Missing access token for credential`)
|
||||
return null
|
||||
}
|
||||
@@ -233,21 +238,20 @@ export async function refreshTokenIfNeeded(
|
||||
credential: any,
|
||||
credentialId: string
|
||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// If token is still valid, return it directly
|
||||
if (!needsRefresh || !credential.refreshToken) {
|
||||
// If token appears valid and present, return it directly
|
||||
if (!shouldRefresh) {
|
||||
logger.info(`[${requestId}] Access token is valid`)
|
||||
return { accessToken: credential.accessToken, refreshed: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { renderHelpConfirmationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
const helpFormSchema = z.object({
|
||||
@@ -28,18 +28,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
@@ -54,18 +42,18 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Validate the form data
|
||||
const result = helpFormSchema.safeParse({
|
||||
const validationResult = helpFormSchema.safeParse({
|
||||
subject,
|
||||
message,
|
||||
type,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid help request data`, {
|
||||
errors: result.error.format(),
|
||||
errors: validationResult.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ error: 'Invalid request data', details: validationResult.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -103,63 +91,60 @@ ${message}
|
||||
emailText += `\n\n${images.length} image(s) attached.`
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const { error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
const emailResult = await sendEmail({
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[${type.toUpperCase()}] ${subject}`,
|
||||
replyTo: email,
|
||||
text: emailText,
|
||||
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
replyTo: email,
|
||||
emailType: 'transactional',
|
||||
attachments: images.map((image) => ({
|
||||
filename: image.filename,
|
||||
content: image.content.toString('base64'),
|
||||
contentType: image.contentType,
|
||||
disposition: 'attachment', // Explicitly set as attachment
|
||||
disposition: 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, error)
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Help request email sent successfully`)
|
||||
|
||||
// Send confirmation email to the user
|
||||
await resend.emails
|
||||
.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
email,
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
images.length
|
||||
)
|
||||
|
||||
await sendEmail({
|
||||
to: [email],
|
||||
subject: `Your ${type} request has been received: ${subject}`,
|
||||
text: `
|
||||
Hello,
|
||||
|
||||
Thank you for your ${type} submission. We've received your request and will get back to you as soon as possible.
|
||||
|
||||
Your message:
|
||||
${message}
|
||||
|
||||
${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
|
||||
|
||||
Best regards,
|
||||
The Sim Team
|
||||
`,
|
||||
html: confirmationHtml,
|
||||
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Help request submitted successfully' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
// Check if error is related to missing API key
|
||||
if (error instanceof Error && error.message.includes('API key')) {
|
||||
logger.error(`[${requestId}] API key configuration error`, error)
|
||||
if (error instanceof Error && error.message.includes('not configured')) {
|
||||
logger.error(`[${requestId}] Email service configuration error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service configuration error. Please check your RESEND_API_KEY.' },
|
||||
{
|
||||
error:
|
||||
'Email service configuration error. Please check your email service configuration.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runs } from '@trigger.dev/sdk/v3'
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
@@ -4,15 +4,50 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
vi.mock('@/lib/logs/console/logger')
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/db')
|
||||
vi.mock('@/lib/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
import { handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch } from './utils'
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateSearchEmbedding,
|
||||
handleTagAndVectorSearch,
|
||||
handleTagOnlySearch,
|
||||
handleVectorOnlySearch,
|
||||
} from './utils'
|
||||
|
||||
describe('Knowledge Search Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handleTagOnlySearch', () => {
|
||||
it('should throw error when no filters provided', async () => {
|
||||
const params = {
|
||||
@@ -140,4 +175,251 @@ describe('Knowledge Search Utils', () => {
|
||||
expect(params.distanceThreshold).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSearchEmbedding', () => {
|
||||
it('should use Azure OpenAI when KB-specific config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no KB Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use default API version when not provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use custom model name when provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Azure OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should handle OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for Azure OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { embedding } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeSearchUtils')
|
||||
|
||||
export class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
content: string
|
||||
@@ -41,61 +29,8 @@ export interface SearchParams {
|
||||
distanceThreshold?: number
|
||||
}
|
||||
|
||||
export async function generateSearchEmbedding(query: string): Promise<number[]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const embedding = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: query,
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
|
||||
throw new Error('Invalid response format from OpenAI embeddings API')
|
||||
}
|
||||
|
||||
return data.data[0].embedding
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30000,
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
return embedding
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate search embedding:', error)
|
||||
throw new Error(
|
||||
`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Use shared embedding utility
|
||||
export { generateSearchEmbedding } from '@/lib/embeddings/utils'
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
|
||||
@@ -252,5 +252,76 @@ describe('Knowledge Utils', () => {
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { processDocument } from '@/lib/documents/document-processor'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { generateEmbeddings } from '@/lib/embeddings/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -10,22 +9,11 @@ import { document, embedding, knowledgeBase } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeUtils')
|
||||
|
||||
// Timeout constants (in milliseconds)
|
||||
const TIMEOUTS = {
|
||||
OVERALL_PROCESSING: 150000, // 150 seconds (2.5 minutes)
|
||||
EMBEDDINGS_API: 60000, // 60 seconds per batch
|
||||
} as const
|
||||
|
||||
class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout wrapper for async operations
|
||||
*/
|
||||
@@ -110,18 +98,6 @@ export interface EmbeddingData {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface OpenAIEmbeddingResponse {
|
||||
data: Array<{
|
||||
embedding: number[]
|
||||
index: number
|
||||
}>
|
||||
model: string
|
||||
usage: {
|
||||
prompt_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseAccessResult {
|
||||
hasAccess: true
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId'>
|
||||
@@ -405,87 +381,8 @@ export async function checkChunkAccess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings using OpenAI API with retry logic for rate limiting
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
embeddingModel = 'text-embedding-3-small'
|
||||
): Promise<number[][]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const batchSize = 100
|
||||
const allEmbeddings: number[][] = []
|
||||
|
||||
for (let i = 0; i < texts.length; i += batchSize) {
|
||||
const batch = texts.slice(i, i + batchSize)
|
||||
|
||||
logger.info(
|
||||
`Generating embeddings for batch ${Math.floor(i / batchSize) + 1} (${batch.length} texts)`
|
||||
)
|
||||
|
||||
const batchEmbeddings = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.EMBEDDINGS_API)
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: batch,
|
||||
model: embeddingModel,
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data: OpenAIEmbeddingResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('OpenAI API request timed out')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 60000, // Max 1 minute delay for embeddings
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
allEmbeddings.push(...batchEmbeddings)
|
||||
}
|
||||
|
||||
return allEmbeddings
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate embeddings:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Export for external use
|
||||
export { generateEmbeddings }
|
||||
|
||||
/**
|
||||
* Process a document asynchronously with full error handling
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
} = body
|
||||
|
||||
logger.info(`[${requestId}] Provider request details`, {
|
||||
@@ -58,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
messageCount: messages?.length || 0,
|
||||
hasEnvironmentVariables:
|
||||
!!environmentVariables && Object.keys(environmentVariables).length > 0,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
let finalApiKey: string
|
||||
@@ -99,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
// Fetch the file from Google Drive API
|
||||
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
|
||||
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
|
||||
}
|
||||
|
||||
// Resolve shortcuts transparently for UI stability
|
||||
if (
|
||||
file.mimeType === 'application/vnd.google-apps.shortcut' &&
|
||||
file.shortcutDetails?.targetId
|
||||
) {
|
||||
const targetId = file.shortcutDetails.targetId
|
||||
const shortcutResp = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (shortcutResp.ok) {
|
||||
const targetFile = await shortcutResp.json()
|
||||
file.id = targetFile.id
|
||||
file.name = targetFile.name
|
||||
file.mimeType = targetFile.mimeType
|
||||
file.iconLink = targetFile.iconLink
|
||||
file.webViewLink = targetFile.webViewLink
|
||||
file.thumbnailLink = targetFile.thumbnailLink
|
||||
file.createdTime = targetFile.createdTime
|
||||
file.modifiedTime = targetFile.modifiedTime
|
||||
file.size = targetFile.size
|
||||
file.owners = targetFile.owners
|
||||
file.exportLinks = targetFile.exportLinks
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
|
||||
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
|
||||
const format = exportFormats[file.mimeType] || 'application/pdf'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const mimeType = searchParams.get('mimeType')
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
// Authorize use of the credential (supports collaborator credentials via workflow)
|
||||
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build the query parameters for Google Drive API
|
||||
let queryParams = 'trashed=false'
|
||||
|
||||
// Add mimeType filter if provided
|
||||
// Build Drive 'q' expression safely
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
|
||||
}
|
||||
if (mimeType) {
|
||||
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
|
||||
// Instead of using the mimeType parameter directly, we'll add it to the query
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and mimeType='${mimeType}'`
|
||||
} else {
|
||||
queryParams += `&q=mimeType='${mimeType}'`
|
||||
}
|
||||
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
|
||||
// Add search query if provided
|
||||
if (query) {
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and name contains '${query}'`
|
||||
} else {
|
||||
queryParams += `&q=name contains '${query}'`
|
||||
}
|
||||
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
const q = encodeURIComponent(qParts.join(' and '))
|
||||
|
||||
// Fetch files from Google Drive API
|
||||
// Fetch files from Google Drive API with shared drives support
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
|
||||
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssueAPI')
|
||||
const logger = createLogger('JiraIssueAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssuesAPI')
|
||||
const logger = createLogger('JiraIssuesAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraProjectsAPI')
|
||||
const logger = createLogger('JiraProjectsAPI')
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraUpdateAPI')
|
||||
const logger = createLogger('JiraUpdateAPI')
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraWriteAPI')
|
||||
const logger = createLogger('JiraWriteAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unstable_noStore as noStore } from 'next/cache'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -10,14 +10,32 @@ export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('WandGenerateAPI')
|
||||
|
||||
const openai = env.OPENAI_API_KEY
|
||||
? new OpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
})
|
||||
: null
|
||||
const azureApiKey = env.AZURE_OPENAI_API_KEY
|
||||
const azureEndpoint = env.AZURE_OPENAI_ENDPOINT
|
||||
const azureApiVersion = env.AZURE_OPENAI_API_VERSION
|
||||
const wandModelName = env.WAND_OPENAI_MODEL_NAME || 'gpt-4o'
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
logger.warn('OPENAI_API_KEY not found. Wand generation API will not function.')
|
||||
const useWandAzure = azureApiKey && azureEndpoint && azureApiVersion
|
||||
|
||||
const client = useWandAzure
|
||||
? new AzureOpenAI({
|
||||
apiKey: azureApiKey,
|
||||
apiVersion: azureApiVersion,
|
||||
endpoint: azureEndpoint,
|
||||
})
|
||||
: openaiApiKey
|
||||
? new OpenAI({
|
||||
apiKey: openaiApiKey,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!useWandAzure && !openaiApiKey) {
|
||||
logger.warn(
|
||||
'Neither Azure OpenAI nor OpenAI API key found. Wand generation API will not function.'
|
||||
)
|
||||
} else {
|
||||
logger.info(`Using ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} for wand generation`)
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -32,14 +50,12 @@ interface RequestBody {
|
||||
history?: ChatMessage[]
|
||||
}
|
||||
|
||||
// The endpoint is now generic - system prompts come from wand configs
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
logger.info(`[${requestId}] Received wand generation request`)
|
||||
|
||||
if (!openai) {
|
||||
logger.error(`[${requestId}] OpenAI client not initialized. Missing API key.`)
|
||||
if (!client) {
|
||||
logger.error(`[${requestId}] AI client not initialized. Missing API key.`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Wand generation service is not configured.' },
|
||||
{ status: 503 }
|
||||
@@ -74,16 +90,19 @@ export async function POST(req: NextRequest) {
|
||||
// Add the current user prompt
|
||||
messages.push({ role: 'user', content: prompt })
|
||||
|
||||
logger.debug(`[${requestId}] Calling OpenAI API for wand generation`, {
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
})
|
||||
logger.debug(
|
||||
`[${requestId}] Calling ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API for wand generation`,
|
||||
{
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
}
|
||||
)
|
||||
|
||||
// For streaming responses
|
||||
if (stream) {
|
||||
try {
|
||||
const streamCompletion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
const streamCompletion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
@@ -141,8 +160,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
const completion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
const completion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
@@ -151,9 +170,11 @@ export async function POST(req: NextRequest) {
|
||||
const generatedContent = completion.choices[0]?.message?.content?.trim()
|
||||
|
||||
if (!generatedContent) {
|
||||
logger.error(`[${requestId}] OpenAI response was empty or invalid.`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to generate content. OpenAI response was empty.' },
|
||||
{ success: false, error: 'Failed to generate content. AI response was empty.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
@@ -171,7 +192,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
status = error.status || 500
|
||||
logger.error(`[${requestId}] OpenAI API Error: ${status} - ${error.message}`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API Error: ${status} - ${error.message}`
|
||||
)
|
||||
|
||||
if (status === 401) {
|
||||
clientErrorMessage = 'Authentication failed. Please check your API key configuration.'
|
||||
@@ -181,6 +204,10 @@ export async function POST(req: NextRequest) {
|
||||
clientErrorMessage =
|
||||
'The wand generation service is currently unavailable. Please try again later.'
|
||||
}
|
||||
} else if (useWandAzure && error.message?.includes('DeploymentNotFound')) {
|
||||
clientErrorMessage =
|
||||
'Azure OpenAI deployment not found. Please check your model deployment configuration.'
|
||||
status = 404
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
|
||||
|
||||
const logger = new Logger('GmailPollingAPI')
|
||||
const logger = createLogger('GmailPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollOutlookWebhooks } from '@/lib/webhooks/outlook-polling-service'
|
||||
|
||||
const logger = new Logger('OutlookPollingAPI')
|
||||
const logger = createLogger('OutlookPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
try {
|
||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureOutlookPolling(
|
||||
workflowRecord.userId,
|
||||
savedWebhook,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk/v3', () => ({
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
@@ -339,7 +339,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk/v3', () => ({
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
@@ -369,7 +369,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk/v3', () => ({
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
@@ -391,7 +391,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
token: 'case-test-token',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk/v3', () => ({
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
@@ -430,7 +430,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
secretHeaderName: 'X-Secret-Key',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk/v3', () => ({
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -2,9 +2,9 @@ import { randomUUID } from 'crypto'
|
||||
import { render } from '@react-email/render'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceInvitationsAPI')
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -241,30 +240,25 @@ async function sendInvitationEmail({
|
||||
})
|
||||
)
|
||||
|
||||
if (!resend) {
|
||||
logger.error('RESEND_API_KEY not configured')
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
|
||||
const fromAddress = `noreply@${emailDomain}`
|
||||
const fromAddress = `${env.SENDER_NAME || 'Sim'} <noreply@${emailDomain}>`
|
||||
|
||||
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromAddress,
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim`,
|
||||
html: emailHtml,
|
||||
from: fromAddress,
|
||||
emailType: 'transactional',
|
||||
useCustomFromFormat: true,
|
||||
})
|
||||
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
if (result.success) {
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
} else {
|
||||
logger.error(`Failed to send invitation email to ${to}`, { error: result.message })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending invitation email:', error)
|
||||
// Continue even if email fails - the invitation is still created
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
@@ -45,11 +46,14 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#ffffff',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
],
|
||||
}
|
||||
|
||||
// Generate dynamic metadata based on brand configuration
|
||||
@@ -70,8 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
|
||||
{/* Meta tags for better SEO */}
|
||||
<meta name='theme-color' content='#ffffff' />
|
||||
<meta name='color-scheme' content='light' />
|
||||
<meta name='color-scheme' content='light dark' />
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
@@ -107,16 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
<ThemeProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
19
apps/sim/app/theme-provider.tsx
Normal file
19
apps/sim/app/theme-provider.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
|
||||
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
// Calculate the left margin based on depth to match the parent span's indentation
|
||||
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
|
||||
|
||||
return (
|
||||
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{/* Input Data - Collapsible */}
|
||||
{span.input && (
|
||||
<div>
|
||||
@@ -162,26 +169,30 @@ function BlockDataDisplay({
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-4 space-y-1'>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -196,10 +207,10 @@ function BlockDataDisplay({
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
@@ -227,12 +238,12 @@ function BlockDataDisplay({
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2'>
|
||||
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
@@ -592,7 +603,9 @@ function TraceSpanItem({
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Block Input/Output Data - Collapsible */}
|
||||
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{/* Children and tool calls */}
|
||||
{/* Render child spans */}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React from 'react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SettingsLoader } from './settings-loader'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
@@ -11,11 +11,12 @@ interface ProvidersProps {
|
||||
|
||||
const Providers = React.memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
/**
|
||||
* Loads user settings from database once per workspace session.
|
||||
* This ensures settings are synced from DB on initial load but uses
|
||||
* localStorage cache for subsequent navigation within the app.
|
||||
*/
|
||||
export function SettingsLoader() {
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only load settings once per session for authenticated users
|
||||
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
// Force load from DB on initial workspace entry
|
||||
loadSettings(true)
|
||||
}
|
||||
}, [isSessionPending, session?.user, loadSettings])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
// If theme is system, check system preference
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.add(prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -29,8 +29,6 @@ export function ChannelSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: ChannelSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
|
||||
// Use the proper hook to get the current value and setter (same as file-selector)
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Reactive upstream fields
|
||||
@@ -43,6 +41,8 @@ export function ChannelSelectorInput({
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to reactive values
|
||||
let credential: string
|
||||
@@ -89,15 +89,10 @@ export function ChannelSelectorInput({
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Slack account or enter a bot token first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
|
||||
@@ -217,17 +216,6 @@ export function CredentialSelector({
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
setStoreValue(credentialId)
|
||||
// If credential changed, clear other sub-block fields for a clean state
|
||||
if (previousId && previousId !== credentialId) {
|
||||
const wfId = (activeWorkflowId as string) || ''
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
Object.keys(blockValues).forEach((key) => {
|
||||
if (key !== subBlock.id) {
|
||||
collaborativeSetSubblockValue(blockId, key, '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
@@ -65,6 +66,9 @@ export function DocumentSelector({
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
// Fetch documents for the selected knowledge base
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
@@ -103,6 +107,7 @@ export function DocumentSelector({
|
||||
// Handle dropdown open/close - fetch documents when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview) return
|
||||
if (isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
@@ -124,13 +129,14 @@ export function DocumentSelector({
|
||||
|
||||
// Sync selected document with value prop
|
||||
useEffect(() => {
|
||||
if (isDisabled) return
|
||||
if (value && documents.length > 0) {
|
||||
const docInfo = documents.find((doc) => doc.id === value)
|
||||
setSelectedDocument(docInfo || null)
|
||||
} else {
|
||||
setSelectedDocument(null)
|
||||
}
|
||||
}, [value, documents])
|
||||
}, [value, documents, isDisabled])
|
||||
|
||||
// Reset documents when knowledge base changes
|
||||
useEffect(() => {
|
||||
@@ -141,10 +147,10 @@ export function DocumentSelector({
|
||||
|
||||
// Fetch documents when knowledge base is available
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId && !isPreview) {
|
||||
if (knowledgeBaseId && !isPreview && !isDisabled) {
|
||||
fetchDocuments()
|
||||
}
|
||||
}, [knowledgeBaseId, isPreview, fetchDocuments])
|
||||
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => {
|
||||
return document.filename
|
||||
@@ -166,9 +172,6 @@ export function DocumentSelector({
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
|
||||
// Show disabled state if no knowledge base is selected
|
||||
const isDisabled = disabled || isPreview || !knowledgeBaseId
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface DropdownProps {
|
||||
previewValue?: string | null
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
config?: import('@/blocks/types').SubBlockConfig
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -34,6 +35,7 @@ export function Dropdown({
|
||||
previewValue,
|
||||
disabled,
|
||||
placeholder = 'Select an option...',
|
||||
config,
|
||||
}: DropdownProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
@@ -281,7 +283,7 @@ export function Dropdown({
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full min-w-[286px]'>
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
|
||||
@@ -376,6 +376,14 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: ConfluenceFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
@@ -547,7 +555,7 @@ export function ConfluenceFileSelector({
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -237,10 +237,11 @@ export function GoogleDrivePicker({
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const url = new URL('/api/auth/oauth/token', window.location.origin)
|
||||
url.searchParams.set('credentialId', effectiveCredentialId)
|
||||
// include workflowId if available via global registry (server adds session owner otherwise)
|
||||
const response = await fetch(url.toString())
|
||||
const response = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch access token: ${response.status}`)
|
||||
@@ -414,6 +415,13 @@ export function GoogleDrivePicker({
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -440,7 +448,7 @@ export function GoogleDrivePicker({
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
@@ -460,7 +468,7 @@ export function GoogleDrivePicker({
|
||||
</Button>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
|
||||
const logger = new Logger('JiraIssueSelector')
|
||||
const logger = createLogger('JiraIssueSelector')
|
||||
|
||||
export interface JiraIssueInfo {
|
||||
id: string
|
||||
|
||||
@@ -727,6 +727,13 @@ export function MicrosoftFileSelector({
|
||||
})
|
||||
: availableFiles
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -750,7 +757,7 @@ export function MicrosoftFileSelector({
|
||||
}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
@@ -925,7 +932,7 @@ export function MicrosoftFileSelector({
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import {
|
||||
type ConfluenceFileInfo,
|
||||
ConfluenceFileSelector,
|
||||
type DiscordChannelInfo,
|
||||
DiscordChannelSelector,
|
||||
type FileInfo,
|
||||
type GoogleCalendarInfo,
|
||||
GoogleCalendarSelector,
|
||||
GoogleDrivePicker,
|
||||
type JiraIssueInfo,
|
||||
JiraIssueSelector,
|
||||
type MicrosoftFileInfo,
|
||||
MicrosoftFileSelector,
|
||||
type TeamsMessageInfo,
|
||||
TeamsMessageSelector,
|
||||
WealthboxFileSelector,
|
||||
type WealthboxItemInfo,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -46,11 +37,12 @@ export function FileSelectorInput({
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: FileSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
// Central dependsOn gating for this selector instance
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Helper to coerce various preview value shapes into a string ID
|
||||
const coerceToIdString = (val: unknown): string => {
|
||||
@@ -72,18 +64,13 @@ export function FileSelectorInput({
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>('')
|
||||
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
|
||||
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
|
||||
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
|
||||
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
|
||||
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
|
||||
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
|
||||
const [domainValue] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
|
||||
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
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
@@ -104,128 +91,29 @@ export function FileSelectorInput({
|
||||
const isWealthbox = provider === 'wealthbox'
|
||||
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain =
|
||||
isConfluence || isJira
|
||||
? (isPreview && previewContextValues?.domain?.value) ||
|
||||
(getValue(blockId, 'domain') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
|
||||
: ''
|
||||
const jiraCredential = isJira
|
||||
? (isPreview && previewContextValues?.credential?.value) ||
|
||||
(getValue(blockId, 'credential') as string) ||
|
||||
(connectedCredential as string) ||
|
||||
''
|
||||
: ''
|
||||
|
||||
// For Discord, we need the bot token and server ID
|
||||
const botToken = isDiscord
|
||||
? (isPreview && previewContextValues?.botToken?.value) ||
|
||||
(getValue(blockId, 'botToken') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
|
||||
: ''
|
||||
const serverId = isDiscord
|
||||
? (isPreview && previewContextValues?.serverId?.value) ||
|
||||
(getValue(blockId, 'serverId') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
|
||||
: ''
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Keep local selection in sync with store value (and preview)
|
||||
useEffect(() => {
|
||||
const raw = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const effective = coerceToIdString(raw)
|
||||
if (effective) {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(effective)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(effective)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(effective)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(effective)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(effective)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(effective)
|
||||
} else {
|
||||
setSelectedFileId(effective)
|
||||
}
|
||||
} else {
|
||||
// Clear when value becomes empty
|
||||
if (isJira) {
|
||||
setSelectedIssueId('')
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId('')
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId('')
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId('')
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId('')
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId('')
|
||||
} else {
|
||||
setSelectedFileId('')
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPreview,
|
||||
previewValue,
|
||||
storeValue,
|
||||
isJira,
|
||||
isDiscord,
|
||||
isMicrosoftTeams,
|
||||
isGoogleCalendar,
|
||||
isWealthbox,
|
||||
isMicrosoftSharePoint,
|
||||
])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (fileId: string, info?: any) => {
|
||||
setSelectedFileId(fileId)
|
||||
setFileInfo(info || null)
|
||||
setStoreValue(fileId)
|
||||
}
|
||||
|
||||
// Handle issue selection
|
||||
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issueKey)
|
||||
setIssueInfo(info || null)
|
||||
setStoreValue(issueKey)
|
||||
|
||||
// Clear the fields when a new issue is selected
|
||||
if (isJira) {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
// Also clear the manual issue key when cleared
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle channel selection
|
||||
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
|
||||
setSelectedChannelId(channelId)
|
||||
setChannelInfo(info || null)
|
||||
setStoreValue(channelId)
|
||||
}
|
||||
|
||||
// Handle calendar selection
|
||||
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
|
||||
setSelectedCalendarId(calendarId)
|
||||
setCalendarInfo(info || null)
|
||||
setStoreValue(calendarId)
|
||||
}
|
||||
|
||||
// Handle Wealthbox item selection
|
||||
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
|
||||
setSelectedWealthboxItemId(itemId)
|
||||
setWealthboxItemInfo(info || null)
|
||||
setStoreValue(itemId)
|
||||
}
|
||||
|
||||
// For Google Drive
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
@@ -245,25 +133,17 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedCalendarId(val)
|
||||
setCalendarInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onCalendarInfoChange={setCalendarInfo}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Google Calendar credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -277,21 +157,18 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<DiscordChannelSelector
|
||||
value={selectedChannelId}
|
||||
onChange={handleChannelChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(channelId) => setStoreValue(channelId)}
|
||||
botToken={botToken}
|
||||
serverId={serverId}
|
||||
label={subBlock.placeholder || 'Select Discord channel'}
|
||||
disabled={disabled || !botToken || !serverId}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(!botToken || !serverId) && (
|
||||
<TooltipContent side='top'>
|
||||
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -311,9 +188,7 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
@@ -321,20 +196,14 @@ export function FileSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Confluence page'}
|
||||
disabled={disabled || !domain}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Confluence domain first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -353,168 +222,139 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedIssueId(val)
|
||||
setIssueInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
onChange={(issueKey) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
|
||||
// Clear related fields when a new issue is selected
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={
|
||||
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
|
||||
}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
|
||||
credentialId={credential}
|
||||
projectId={(getValue(blockId, 'projectId') as string) || ''}
|
||||
projectId={(projectIdValue as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !credential ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Jira credentials first</p>
|
||||
</TooltipContent>
|
||||
) : !(getValue(blockId, 'projectId') as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira project first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMicrosoftExcel) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Excel credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Word selector
|
||||
// Microsoft Word selector
|
||||
if (isMicrosoftWord) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-word'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Word document'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Word credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft OneDrive selector
|
||||
// Microsoft OneDrive selector
|
||||
if (isMicrosoftOneDrive) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select OneDrive folder'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft SharePoint selector
|
||||
// Microsoft SharePoint selector
|
||||
if (isMicrosoftSharePoint) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select SharePoint site'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
@@ -531,26 +371,26 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Planner task selector
|
||||
// Microsoft Planner task selector
|
||||
if (isMicrosoftPlanner) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const planId = (getValue(blockId, 'planId') as string) || ''
|
||||
|
||||
const planId = (planIdValue as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-planner'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId='microsoft-planner'
|
||||
label={subBlock.placeholder || 'Select task'}
|
||||
disabled={disabled || !credential || !planId}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
planId={planId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
@@ -572,32 +412,22 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Teams selector
|
||||
// Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Determine the selector type based on the subBlock ID
|
||||
// Determine the selector type based on the subBlock ID / operation
|
||||
let selectionType: 'team' | 'channel' | 'chat' = 'team'
|
||||
|
||||
if (subBlock.id === 'teamId') {
|
||||
selectionType = 'team'
|
||||
} else if (subBlock.id === 'channelId') {
|
||||
selectionType = 'channel'
|
||||
} else if (subBlock.id === 'chatId') {
|
||||
selectionType = 'chat'
|
||||
} else {
|
||||
// Fallback: look at the operation to determine the selection type
|
||||
const operation = (getValue(blockId, 'operation') as string) || ''
|
||||
if (operation.includes('chat')) {
|
||||
selectionType = 'chat'
|
||||
} else if (operation.includes('channel')) {
|
||||
selectionType = 'channel'
|
||||
}
|
||||
if (subBlock.id === 'teamId') selectionType = 'team'
|
||||
else if (subBlock.id === 'channelId') selectionType = 'channel'
|
||||
else if (subBlock.id === 'chatId') selectionType = 'chat'
|
||||
else {
|
||||
const operation = (operationValue as string) || ''
|
||||
if (operation.includes('chat')) selectionType = 'chat'
|
||||
else if (operation.includes('channel')) selectionType = 'channel'
|
||||
}
|
||||
|
||||
// Get the teamId from workflow parameters for channel selector
|
||||
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
|
||||
const selectedTeamId = (teamIdValue as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -610,10 +440,8 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(value, info) => {
|
||||
setSelectedMessageId(value)
|
||||
setMessageInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='microsoft-teams'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
@@ -621,7 +449,6 @@ export function FileSelectorInput({
|
||||
label={subBlock.placeholder || 'Select Teams message location'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onMessageInfoChange={setMessageInfo}
|
||||
credential={credential}
|
||||
selectionType={selectionType}
|
||||
initialTeamId={selectedTeamId}
|
||||
@@ -640,15 +467,11 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Render Wealthbox selector
|
||||
// Wealthbox selector
|
||||
if (isWealthbox) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Only handle contacts now - both notes and tasks use short-input
|
||||
if (subBlock.id === 'contactId') {
|
||||
const itemType = 'contact'
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -660,9 +483,7 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedWealthboxItemId(val)
|
||||
setWealthboxItemInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
@@ -671,7 +492,6 @@ export function FileSelectorInput({
|
||||
label={subBlock.placeholder || `Select ${itemType}`}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setWealthboxItemInfo}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
@@ -686,7 +506,7 @@ export function FileSelectorInput({
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
// If it's noteId or taskId, we should not render the file selector since they now use short-input
|
||||
// noteId or taskId now use short-input
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -705,9 +525,7 @@ export function FileSelectorInput({
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
@@ -717,7 +535,6 @@ export function FileSelectorInput({
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={credential}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -37,8 +38,13 @@ export function FolderSelectorInput({
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
// When gated/disabled, do not set defaults or write to store
|
||||
if (finalDisabled) return
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
return
|
||||
@@ -54,7 +60,15 @@ export function FolderSelectorInput({
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
}
|
||||
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
|
||||
}, [
|
||||
blockId,
|
||||
subBlock.id,
|
||||
storeValue,
|
||||
collaborativeSetSubblockValue,
|
||||
isPreview,
|
||||
previewValue,
|
||||
finalDisabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
|
||||
@@ -72,7 +86,7 @@ export function FolderSelectorInput({
|
||||
provider={subBlock.provider || 'google-email'}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select folder'}
|
||||
disabled={disabled}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
onFolderInfoChange={setFolderInfo}
|
||||
credentialId={(connectedCredential as string) || ''}
|
||||
|
||||
@@ -279,29 +279,33 @@ export function FolderSelector({
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [fetchCredentials, disabled])
|
||||
|
||||
// Fetch folders when credential is selected
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (selectedCredentialId) {
|
||||
fetchFolders()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFolders])
|
||||
}, [selectedCredentialId, fetchFolders, disabled])
|
||||
|
||||
// Keep internal selectedFolderId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue !== selectedFolderId) {
|
||||
setSelectedFolderId(currentValue || '')
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
}, [value, isPreview, previewValue, disabled])
|
||||
|
||||
// Fetch the selected folder metadata once credentials are ready or value changes
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? (previewValue as string) : (value as string)
|
||||
if (
|
||||
currentValue &&
|
||||
@@ -310,7 +314,15 @@ export function FolderSelector({
|
||||
) {
|
||||
fetchFolderById(currentValue)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue])
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectedFolder,
|
||||
fetchFolderById,
|
||||
isPreview,
|
||||
previewValue,
|
||||
disabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFolder = (folder: FolderInfo) => {
|
||||
|
||||
@@ -353,6 +353,14 @@ export function JiraProjectSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear local preview when value is cleared remotely or via collaborator
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}, [value, onProjectInfoChange])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -387,6 +395,8 @@ export function JiraProjectSelector({
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -399,7 +409,7 @@ export function JiraProjectSelector({
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
{selectedProject ? (
|
||||
{canShowPreview ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProject.name}</span>
|
||||
@@ -546,7 +556,7 @@ export function JiraProjectSelector({
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type LinearTeamInfo,
|
||||
LinearTeamSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -51,16 +52,11 @@ export function ProjectSelectorInput({
|
||||
subBlock.provider || subBlock.serviceId || 'jira',
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
// Local setters for related Jira fields to ensure immediate UI clearing
|
||||
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
|
||||
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
|
||||
blockId,
|
||||
'manualIssueKey'
|
||||
)
|
||||
// Reactive dependencies from store for Linear
|
||||
const [linearCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'jira'
|
||||
@@ -95,27 +91,6 @@ export function ProjectSelectorInput({
|
||||
setProjectInfo(info || null)
|
||||
setStoreValue(projectId)
|
||||
|
||||
// Clear the issue-related fields when a new project is selected
|
||||
if (provider === 'jira') {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
// Clear both the basic and advanced issue key fields to ensure UI resets
|
||||
collaborativeSetSubblockValue(blockId, 'issueKey', '')
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
// Also clear locally for immediate UI feedback on this client
|
||||
setIssueKeyValue('')
|
||||
setManualIssueKeyValue('')
|
||||
} else if (provider === 'discord') {
|
||||
collaborativeSetSubblockValue(blockId, 'channelId', '')
|
||||
} else if (provider === 'linear') {
|
||||
if (subBlock.id === 'teamId') {
|
||||
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', '')
|
||||
} else if (subBlock.id === 'projectId') {
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
|
||||
}
|
||||
}
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
}
|
||||
|
||||
@@ -163,7 +138,7 @@ export function ProjectSelectorInput({
|
||||
}}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={disabled || !(linearCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
@@ -171,7 +146,7 @@ export function ProjectSelectorInput({
|
||||
(() => {
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = disabled || !credential || !teamId
|
||||
const isDisabled = finalDisabled
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
value={selectedProjectId}
|
||||
@@ -213,7 +188,7 @@ export function ProjectSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={disabled || !domain || !(jiraCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
@@ -222,15 +197,6 @@ export function ProjectSelectorInput({
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !(jiraCredential as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira account first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,10 +8,16 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
@@ -59,27 +65,31 @@ export function FieldFormat({
|
||||
emptyMessage = 'No fields defined',
|
||||
showType = true,
|
||||
showValue = false,
|
||||
valuePlaceholder = 'Enter value or <variable.name>',
|
||||
valuePlaceholder = 'Enter test value',
|
||||
isConnecting = false,
|
||||
config,
|
||||
}: FieldFormatProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
|
||||
const [tagDropdownStates, setTagDropdownStates] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
visible: boolean
|
||||
cursorPosition: number
|
||||
}
|
||||
>
|
||||
>({})
|
||||
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({})
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const fields: Field[] = value || []
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
;(fields || []).forEach((f) => {
|
||||
if (localValues[f.id] === undefined) {
|
||||
initial[f.id] = (f.value as string) || ''
|
||||
}
|
||||
})
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setLocalValues((prev) => ({ ...prev, ...initial }))
|
||||
}
|
||||
}, [fields])
|
||||
|
||||
// Field operations
|
||||
const addField = () => {
|
||||
if (isPreview || disabled) return
|
||||
@@ -88,12 +98,12 @@ export function FieldFormat({
|
||||
...DEFAULT_FIELD,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setStoreValue([...fields, newField])
|
||||
setStoreValue([...(fields || []), newField])
|
||||
}
|
||||
|
||||
const removeField = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.filter((field: Field) => field.id !== id))
|
||||
setStoreValue((fields || []).filter((field: Field) => field.id !== id))
|
||||
}
|
||||
|
||||
// Validate field name for API safety
|
||||
@@ -103,38 +113,22 @@ export function FieldFormat({
|
||||
return name.replace(/[\x00-\x1F"\\]/g, '').trim()
|
||||
}
|
||||
|
||||
// Tag dropdown handlers
|
||||
const handleValueInputChange = (fieldId: string, newValue: string) => {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
const cursorPosition = input.selectionStart || 0
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
|
||||
updateField(fieldId, 'value', newValue)
|
||||
setLocalValues((prev) => ({ ...prev, [fieldId]: newValue }))
|
||||
}
|
||||
|
||||
const handleTagSelect = (fieldId: string, newValue: string) => {
|
||||
updateField(fieldId, 'value', newValue)
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
}
|
||||
// Value normalization: keep it simple for string types
|
||||
|
||||
const handleTagDropdownClose = (fieldId: string) => {
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
const handleValueInputBlur = (field: Field) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const inputEl = valueInputRefs.current[field.id]
|
||||
if (!inputEl) return
|
||||
|
||||
const current = localValues[field.id] ?? inputEl.value ?? ''
|
||||
const trimmed = current.trim()
|
||||
if (!trimmed) return
|
||||
updateField(field.id, 'value', current)
|
||||
}
|
||||
|
||||
// Drag and drop handlers for connection blocks
|
||||
@@ -152,47 +146,8 @@ export function FieldFormat({
|
||||
const handleDrop = (e: React.DragEvent, fieldId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [fieldId]: false }))
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock' && data.connectionData) {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
// Focus the input first
|
||||
input.focus()
|
||||
|
||||
// Get current cursor position or use end of field
|
||||
const dropPosition = input.selectionStart ?? (input.value?.length || 0)
|
||||
|
||||
// Insert '<' at drop position to trigger the dropdown
|
||||
const currentValue = input.value || ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
// Update the field value
|
||||
updateField(fieldId, 'value', newValue)
|
||||
|
||||
// Set cursor position and show dropdown
|
||||
setTimeout(() => {
|
||||
input.selectionStart = dropPosition + 1
|
||||
input.selectionEnd = dropPosition + 1
|
||||
|
||||
// Trigger dropdown by simulating the tag check
|
||||
const cursorPosition = dropPosition + 1
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
}, 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling drop:', error)
|
||||
}
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
input?.focus()
|
||||
}
|
||||
|
||||
// Update handlers
|
||||
@@ -204,12 +159,14 @@ export function FieldFormat({
|
||||
value = validateFieldName(value)
|
||||
}
|
||||
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
setStoreValue((fields || []).map((f: Field) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
}
|
||||
|
||||
const toggleCollapse = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
|
||||
setStoreValue(
|
||||
(fields || []).map((f: Field) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))
|
||||
)
|
||||
}
|
||||
|
||||
// Field header
|
||||
@@ -371,54 +328,66 @@ export function FieldFormat({
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Value</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={field.value || ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleTagDropdownClose(field.id)
|
||||
{field.type === 'boolean' ? (
|
||||
<Select
|
||||
value={localValues[field.id] ?? (field.value as string) ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setLocalValues((prev) => ({ ...prev, [field.id]: v }))
|
||||
if (!isPreview && !disabled) updateField(field.id, 'value', v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9 w-full justify-between font-normal'>
|
||||
<SelectValue placeholder='Select value' className='truncate' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>true</SelectItem>
|
||||
<SelectItem value='false'>false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'object' || field.type === 'array' ? (
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={localValues[field.id] ?? (field.value as string) ?? ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onBlur={() => handleValueInputBlur(field)}
|
||||
placeholder={
|
||||
field.type === 'object' ? '{\n "key": "value"\n}' : '[\n 1, 2, 3\n]'
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, field.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, field.id)}
|
||||
onDrop={(e) => handleDrop(e, field.id)}
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
{field.value && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center px-3 py-2'>
|
||||
<div className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm'>
|
||||
{formatDisplayText(field.value, true)}
|
||||
</div>
|
||||
</div>
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'min-h-[120px] font-mono text-sm placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={localValues[field.id] ?? field.value ?? ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onBlur={() => handleValueInputBlur(field)}
|
||||
onDragOver={(e) => handleDragOver(e, field.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, field.id)}
|
||||
onDrop={(e) => handleDrop(e, field.id)}
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'h-9 placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<TagDropdown
|
||||
visible={tagDropdownStates[field.id]?.visible || false}
|
||||
onSelect={(newValue) => handleTagSelect(field.id, newValue)}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={field.value || ''}
|
||||
cursorPosition={tagDropdownStates[field.id]?.cursorPosition || 0}
|
||||
onClose={() => handleTagDropdownClose(field.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,7 +429,7 @@ export function ResponseFormat(
|
||||
emptyMessage='No response fields defined'
|
||||
showType={false}
|
||||
showValue={true}
|
||||
valuePlaceholder='Enter value or <variable.name>'
|
||||
valuePlaceholder='Enter test value'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { logger } from '@trigger.dev/sdk/v3'
|
||||
import { PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider, OAuthService } from '@/lib/oauth/oauth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
type ToolParameterConfig,
|
||||
} from '@/tools/params'
|
||||
|
||||
const logger = createLogger('ToolInput')
|
||||
|
||||
interface ToolInputProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -46,18 +46,51 @@ export function TriggerModal({
|
||||
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Track if config has changed from initial values
|
||||
// Snapshot initial values at open for stable dirty-checking across collaborators
|
||||
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
|
||||
const initialCredentialRef = useRef<string | null>(null)
|
||||
|
||||
// Capture initial credential on first detect
|
||||
useEffect(() => {
|
||||
if (initialCredentialRef.current !== null) return
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
|
||||
initialCredentialRef.current = cred
|
||||
}, [blockId])
|
||||
|
||||
// Track if config has changed from initial snapshot
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
return JSON.stringify(config) !== JSON.stringify(initialConfig)
|
||||
}, [config, initialConfig])
|
||||
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
|
||||
}, [config])
|
||||
|
||||
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
|
||||
let hasCredentialChanged = false
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [generatedPath, setGeneratedPath] = useState('')
|
||||
const [hasCredentials, setHasCredentials] = useState(false)
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
|
||||
const [dynamicOptions, setDynamicOptions] = useState<
|
||||
Record<string, Array<{ id: string; name: string }>>
|
||||
>({})
|
||||
const lastCredentialIdRef = useRef<string | null>(null)
|
||||
|
||||
// Reset provider-dependent config fields when credentials change
|
||||
const resetFieldsForCredentialChange = () => {
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev }
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
if (Array.isArray(next.labelIds)) next.labelIds = []
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
if (Array.isArray(next.folderIds)) next.folderIds = []
|
||||
} else if (triggerDef.provider === 'airtable') {
|
||||
if (typeof next.baseId === 'string') next.baseId = ''
|
||||
if (typeof next.tableId === 'string') next.tableId = ''
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize config with default values from trigger definition
|
||||
useEffect(() => {
|
||||
@@ -79,35 +112,71 @@ export function TriggerModal({
|
||||
}
|
||||
}, [triggerDef.configFields, initialConfig])
|
||||
|
||||
// Monitor credential selection
|
||||
// Monitor credential selection across collaborators; clear options on change/clear
|
||||
useEffect(() => {
|
||||
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
|
||||
// Check if credentials are selected by monitoring the sub-block store
|
||||
const checkCredentials = () => {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
|
||||
const hasCredential = Boolean(credentialValue)
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
|
||||
| string
|
||||
| null
|
||||
const currentCredentialId = credentialValue || null
|
||||
const hasCredential = Boolean(currentCredentialId)
|
||||
setHasCredentials(hasCredential)
|
||||
|
||||
// If credential changed and it's a Gmail trigger, load labels
|
||||
if (hasCredential && credentialValue !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialValue)
|
||||
// If credential was cleared by another user, reset local state and dynamic options
|
||||
if (!hasCredential) {
|
||||
if (selectedCredentialId !== null) {
|
||||
setSelectedCredentialId(null)
|
||||
}
|
||||
// Clear provider-specific dynamic options
|
||||
setDynamicOptions({})
|
||||
// Per requirements: only clear dependent selections on actual credential CHANGE,
|
||||
// not when it becomes empty. So we do NOT reset fields here.
|
||||
lastCredentialIdRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// If credential changed, clear options immediately and load for new cred
|
||||
const previousCredentialId = lastCredentialIdRef.current
|
||||
|
||||
// First detection (prev null → current non-null): do not clear selections
|
||||
if (previousCredentialId === null) {
|
||||
setSelectedCredentialId(currentCredentialId)
|
||||
lastCredentialIdRef.current = currentCredentialId
|
||||
if (typeof currentCredentialId === 'string') {
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
void loadGmailLabels(currentCredentialId)
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
void loadOutlookFolders(currentCredentialId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Real change (prev non-null → different non-null): clear dependent selections
|
||||
if (
|
||||
typeof currentCredentialId === 'string' &&
|
||||
currentCredentialId !== previousCredentialId
|
||||
) {
|
||||
setSelectedCredentialId(currentCredentialId)
|
||||
lastCredentialIdRef.current = currentCredentialId
|
||||
// Clear stale options before loading new ones
|
||||
setDynamicOptions({})
|
||||
// Clear any selected values that depend on the credential
|
||||
resetFieldsForCredentialChange()
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
loadGmailLabels(credentialValue)
|
||||
void loadGmailLabels(currentCredentialId)
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
loadOutlookFolders(credentialValue)
|
||||
void loadOutlookFolders(currentCredentialId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkCredentials()
|
||||
|
||||
// Set up a subscription to monitor changes
|
||||
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
// If credentials aren't required, set to true
|
||||
setHasCredentials(true)
|
||||
}, [
|
||||
blockId,
|
||||
@@ -367,10 +436,14 @@ export function TriggerModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isConfigValid() || (!hasConfigChanged && !!triggerId)}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!isConfigValid() ||
|
||||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
|
||||
}
|
||||
className={cn(
|
||||
'h-10',
|
||||
isConfigValid() && (hasConfigChanged || !triggerId)
|
||||
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
|
||||
? 'bg-primary hover:bg-primary/90'
|
||||
: '',
|
||||
isSaving &&
|
||||
|
||||
@@ -172,6 +172,11 @@ export function TriggerConfig({
|
||||
// Map trigger ID to webhook provider name
|
||||
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
|
||||
|
||||
// Include selected credential from the modal (if any)
|
||||
const selectedCredentialId =
|
||||
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
|
||||
null
|
||||
|
||||
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
|
||||
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
|
||||
// Gmail polling service requires a webhook database entry to find the configuration
|
||||
@@ -185,7 +190,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path: '', // Empty path - API will generate dummy path for Gmail
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -225,7 +233,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path,
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
|
||||
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
|
||||
|
||||
const logger = new Logger('GmailConfig')
|
||||
const logger = createLogger('GmailConfig')
|
||||
|
||||
const TOOLTIPS = {
|
||||
labels: 'Select which email labels to monitor.',
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
|
||||
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
|
||||
|
||||
const logger = new Logger('OutlookConfig')
|
||||
const logger = createLogger('OutlookConfig')
|
||||
|
||||
interface OutlookFolder {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
* Centralized dependsOn gating for sub-block components.
|
||||
* - Computes dependency values from the active workflow/block
|
||||
* - Returns a stable disabled flag to pass to inputs and to guard effects
|
||||
*/
|
||||
export function useDependsOnGate(
|
||||
blockId: string,
|
||||
subBlock: SubBlockConfig,
|
||||
opts?: { disabled?: boolean; isPreview?: boolean }
|
||||
) {
|
||||
const disabledProp = opts?.disabled ?? false
|
||||
const isPreview = opts?.isPreview ?? false
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
// Use only explicit dependsOn from block config. No inference.
|
||||
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
|
||||
|
||||
const dependencyValues = useSubBlockStore((state) => {
|
||||
if (dependsOn.length === 0) return [] as any[]
|
||||
if (!activeWorkflowId) return dependsOn.map(() => null)
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
|
||||
}) as any[]
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
if (dependsOn.length === 0) return true
|
||||
return dependencyValues.every((v) =>
|
||||
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
|
||||
)
|
||||
}, [dependencyValues, dependsOn])
|
||||
|
||||
// Block everything except the credential field itself until dependencies are set
|
||||
const blocked =
|
||||
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
|
||||
|
||||
const finalDisabled = disabledProp || isPreview || blocked
|
||||
|
||||
return {
|
||||
dependsOn,
|
||||
dependencyValues,
|
||||
depsSatisfied,
|
||||
blocked,
|
||||
finalDisabled,
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
|
||||
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
|
||||
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
|
||||
// This ensures our selector recomputes when the active workflow changes.
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const blockType = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
|
||||
)
|
||||
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
|
||||
const streamingValueRef = useRef<T | null>(null)
|
||||
const wasStreamingRef = useRef<boolean>(false)
|
||||
|
||||
// Get value from subblock store - always call this hook unconditionally
|
||||
// Get value from subblock store, keyed by active workflow id
|
||||
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
|
||||
const storeValue = useSubBlockStore(
|
||||
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return null
|
||||
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
|
||||
},
|
||||
[activeWorkflowId, blockId, subBlockId]
|
||||
)
|
||||
)
|
||||
|
||||
// Check if we're in diff mode and get diff value if available
|
||||
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
|
||||
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
|
||||
[activeWorkflowId || '']: {
|
||||
...state.workflowValues[activeWorkflowId || ''],
|
||||
[blockId]: {
|
||||
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
|
||||
blockId
|
||||
],
|
||||
...state.workflowValues[activeWorkflowId || '']?.[blockId],
|
||||
[subBlockId]: newValue,
|
||||
},
|
||||
},
|
||||
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
|
||||
isStreaming,
|
||||
emitValue,
|
||||
isShowingDiff,
|
||||
activeWorkflowId,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -126,9 +126,12 @@ export function SubBlock({
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
options={config.options as { label: string; id: string }[]}
|
||||
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
|
||||
placeholder={config.placeholder}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
disabled={isDisabled}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -139,6 +142,7 @@ export function SubBlock({
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
options={config.options as { label: string; id: string }[]}
|
||||
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
|
||||
placeholder={config.placeholder}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
@@ -435,6 +439,7 @@ export function SubBlock({
|
||||
disabled={isDisabled}
|
||||
isConnecting={isConnecting}
|
||||
config={config}
|
||||
showValue={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -548,8 +548,8 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
})
|
||||
|
||||
// Merge subblock states from the appropriate store
|
||||
const mergedStates = mergeSubblockState(validBlocks)
|
||||
// Merge subblock states from the appropriate store (scoped to active workflow)
|
||||
const mergedStates = mergeSubblockState(validBlocks, activeWorkflowId ?? undefined)
|
||||
|
||||
// Debug: Check for blocks with undefined types after merging
|
||||
Object.entries(mergedStates).forEach(([blockId, block]) => {
|
||||
@@ -807,7 +807,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Continue execution until there are no more pending blocks
|
||||
let iterationCount = 0
|
||||
const maxIterations = 100 // Safety to prevent infinite loops
|
||||
const maxIterations = 500 // Safety to prevent infinite loops
|
||||
|
||||
while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) {
|
||||
logger.info(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logger } from '@sentry/nextjs'
|
||||
import { Download, Folder, Plus } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateFolderName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -14,7 +14,8 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
|
||||
|
||||
// Constants
|
||||
const logger = createLogger('CreateMenu')
|
||||
|
||||
const TIMERS = {
|
||||
LONG_PRESS_DELAY: 500,
|
||||
CLOSE_DELAY: 150,
|
||||
|
||||
@@ -45,14 +45,15 @@ export function General() {
|
||||
const toggleConsoleExpandedByDefault = useGeneralStore(
|
||||
(state) => state.toggleConsoleExpandedByDefault
|
||||
)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
|
||||
// Sync theme from store to next-themes when theme changes
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await loadSettings()
|
||||
if (!isLoading && theme) {
|
||||
// Ensure next-themes is in sync with our store
|
||||
const { syncThemeToNextThemes } = require('@/lib/theme-sync')
|
||||
syncThemeToNextThemes(theme)
|
||||
}
|
||||
loadData()
|
||||
}, [loadSettings])
|
||||
}, [theme, isLoading])
|
||||
|
||||
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
|
||||
await setTheme(value)
|
||||
|
||||
@@ -720,21 +720,47 @@ export function Sidebar() {
|
||||
`[data-workflow-id="${workflowId}"]`
|
||||
) as HTMLElement
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'start',
|
||||
})
|
||||
// Check if this is a newly created workflow (created within the last 5 seconds)
|
||||
const currentWorkflow = workflows[workflowId]
|
||||
const isNewlyCreated =
|
||||
currentWorkflow &&
|
||||
currentWorkflow.lastModified instanceof Date &&
|
||||
Date.now() - currentWorkflow.lastModified.getTime() < 5000 // 5 seconds
|
||||
|
||||
// Adjust scroll position to eliminate the small gap at the top
|
||||
const scrollViewport = scrollContainer.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (scrollViewport && scrollViewport.scrollTop > 0) {
|
||||
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
|
||||
if (isNewlyCreated) {
|
||||
// For newly created workflows, use the original behavior - scroll to top
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'start',
|
||||
})
|
||||
|
||||
// Adjust scroll position to eliminate the small gap at the top
|
||||
const scrollViewport = scrollContainer.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (scrollViewport && scrollViewport.scrollTop > 0) {
|
||||
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
|
||||
}
|
||||
} else {
|
||||
// For existing workflows, check if already visible and scroll minimally
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const workflowRect = activeWorkflow.getBoundingClientRect()
|
||||
|
||||
// Only scroll if the workflow is not fully visible
|
||||
const isFullyVisible =
|
||||
workflowRect.top >= containerRect.top && workflowRect.bottom <= containerRect.bottom
|
||||
|
||||
if (!isFullyVisible) {
|
||||
// Use 'nearest' to scroll minimally - only bring into view, don't force to top
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workflowId, isLoading])
|
||||
}, [workflowId, isLoading, workflows])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { task } from '@trigger.dev/sdk/v3'
|
||||
import { task } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { task } from '@trigger.dev/sdk/v3'
|
||||
import { task } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
getProviderIcon,
|
||||
MODELS_TEMP_RANGE_0_1,
|
||||
MODELS_TEMP_RANGE_0_2,
|
||||
MODELS_WITH_REASONING_EFFORT,
|
||||
MODELS_WITH_TEMPERATURE_SUPPORT,
|
||||
MODELS_WITH_VERBOSITY,
|
||||
providers,
|
||||
} from '@/providers/utils'
|
||||
|
||||
@@ -210,6 +212,41 @@ Create a system prompt appropriately detailed for the request, using clear langu
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reasoningEffort',
|
||||
title: 'Reasoning Effort',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
placeholder: 'Select reasoning effort...',
|
||||
options: [
|
||||
{ label: 'minimal', id: 'minimal' },
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
value: () => 'medium',
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: MODELS_WITH_REASONING_EFFORT,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'verbosity',
|
||||
title: 'Verbosity',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
placeholder: 'Select verbosity...',
|
||||
options: [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
value: () => 'medium',
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: MODELS_WITH_VERBOSITY,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
@@ -485,6 +522,8 @@ Example 3 (Array Input):
|
||||
},
|
||||
},
|
||||
temperature: { type: 'number', description: 'Response randomness level' },
|
||||
reasoningEffort: { type: 'string', description: 'Reasoning effort level for GPT-5 models' },
|
||||
verbosity: { type: 'string', description: 'Verbosity level for GPT-5 models' },
|
||||
tools: { type: 'json', description: 'Available tools configuration' },
|
||||
},
|
||||
outputs: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -53,6 +54,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
|
||||
dependsOn: ['credential', 'baseId'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
provider: 'confluence',
|
||||
serviceId: 'confluence',
|
||||
placeholder: 'Select Confluence page',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual page ID input (advanced mode)
|
||||
|
||||
@@ -43,6 +43,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
provider: 'discord',
|
||||
serviceId: 'discord',
|
||||
placeholder: 'Select Discord server',
|
||||
dependsOn: ['botToken'],
|
||||
mode: 'basic',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
@@ -71,6 +72,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
provider: 'discord',
|
||||
serviceId: 'discord',
|
||||
placeholder: 'Select Discord channel',
|
||||
dependsOn: ['botToken', 'serverId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
|
||||
},
|
||||
|
||||
@@ -106,6 +106,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
placeholder: 'Select Gmail label/folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'read_gmail' },
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
serviceId: 'google-calendar',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select calendar',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual calendar ID input (advanced mode)
|
||||
|
||||
@@ -49,6 +49,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.document',
|
||||
placeholder: 'Select a document',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read', 'write'] },
|
||||
},
|
||||
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter document ID',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['read', 'write'] },
|
||||
},
|
||||
@@ -83,6 +85,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
},
|
||||
@@ -93,6 +96,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
{
|
||||
@@ -155,6 +156,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
// Manual Folder ID input (advanced mode)
|
||||
@@ -179,6 +181,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
// Manual Folder ID input (advanced mode)
|
||||
|
||||
@@ -50,6 +50,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual Spreadsheet ID (advanced mode)
|
||||
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'ID of the spreadsheet (from URL)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Range
|
||||
|
||||
@@ -62,6 +62,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira project',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual project ID input (advanced mode)
|
||||
@@ -71,6 +72,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Jira project ID',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Issue selector (basic mode)
|
||||
@@ -82,6 +84,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira issue',
|
||||
dependsOn: ['credential', 'domain', 'projectId'],
|
||||
condition: { field: 'operation', value: ['read', 'update'] },
|
||||
mode: 'basic',
|
||||
},
|
||||
@@ -92,6 +95,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Jira issue key',
|
||||
dependsOn: ['credential', 'domain', 'projectId'],
|
||||
condition: { field: 'operation', value: ['read', 'update'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
type: 'document-selector',
|
||||
layout: 'full',
|
||||
placeholder: 'Select document',
|
||||
dependsOn: ['knowledgeBaseId'],
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'upload_chunk' },
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
provider: 'linear',
|
||||
serviceId: 'linear',
|
||||
placeholder: 'Select a team',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
@@ -52,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
provider: 'linear',
|
||||
serviceId: 'linear',
|
||||
placeholder: 'Select a project',
|
||||
dependsOn: ['credential', 'teamId'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual team ID input (advanced mode)
|
||||
|
||||
@@ -46,6 +46,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter spreadsheet ID',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
layout: 'full',
|
||||
placeholder: 'Enter the plan ID',
|
||||
condition: { field: 'operation', value: ['create_task', 'read_task'] },
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
{
|
||||
id: 'taskId',
|
||||
@@ -70,6 +71,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
placeholder: 'Select a task',
|
||||
provider: 'microsoft-planner',
|
||||
condition: { field: 'operation', value: ['read_task'] },
|
||||
dependsOn: ['credential', 'planId'],
|
||||
mode: 'basic',
|
||||
},
|
||||
|
||||
@@ -81,6 +83,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
layout: 'full',
|
||||
placeholder: 'Enter the task ID',
|
||||
condition: { field: 'operation', value: ['read_task'] },
|
||||
dependsOn: ['credential', 'planId'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a team',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
|
||||
},
|
||||
@@ -82,6 +83,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a chat',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
|
||||
},
|
||||
@@ -103,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a channel',
|
||||
dependsOn: ['credential', 'teamId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
@@ -115,6 +117,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
@@ -125,6 +128,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
@@ -146,6 +150,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
@@ -156,6 +161,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
serviceId: 'outlook',
|
||||
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
|
||||
placeholder: 'Select Outlook folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'read_outlook' },
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a site',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['create_page', 'read_page', 'list_sites'] },
|
||||
},
|
||||
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter site ID (leave empty for root site)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create_page' },
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ export const StarterBlock: BlockConfig = {
|
||||
type: 'starter',
|
||||
name: 'Starter',
|
||||
description: 'Start workflow',
|
||||
longDescription: 'Initiate your workflow manually with optional structured input for API calls.',
|
||||
longDescription: 'Initiate your workflow manually with optional structured input.',
|
||||
category: 'blocks',
|
||||
bgColor: '#2FB3FF',
|
||||
icon: StartIcon,
|
||||
@@ -25,9 +25,11 @@ export const StarterBlock: BlockConfig = {
|
||||
// Structured Input format - visible if manual run is selected (advanced mode)
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Input Format (for API calls)',
|
||||
title: 'Input Format',
|
||||
type: 'input-format',
|
||||
layout: 'full',
|
||||
description:
|
||||
'Name and Type define your input schema. Value is used only for manual test runs.',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'startWorkflow', value: 'manual' },
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
{ label: 'Create a Row', id: 'insert' },
|
||||
{ label: 'Update a Row', id: 'update' },
|
||||
{ label: 'Delete a Row', id: 'delete' },
|
||||
{ label: 'Upsert a Row', id: 'upsert' },
|
||||
],
|
||||
value: () => 'query',
|
||||
},
|
||||
@@ -75,6 +76,15 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: 'Data',
|
||||
type: 'code',
|
||||
layout: 'full',
|
||||
placeholder: '{\n "column1": "value1",\n "column2": "value2"\n}',
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
required: true,
|
||||
},
|
||||
// Filter for get_row, update, delete operations (required)
|
||||
{
|
||||
id: 'filter',
|
||||
@@ -138,6 +148,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
'supabase_get_row',
|
||||
'supabase_update',
|
||||
'supabase_delete',
|
||||
'supabase_upsert',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -152,6 +163,8 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
return 'supabase_update'
|
||||
case 'delete':
|
||||
return 'supabase_delete'
|
||||
case 'upsert':
|
||||
return 'supabase_upsert'
|
||||
default:
|
||||
throw new Error(`Invalid Supabase operation: ${params.operation}`)
|
||||
}
|
||||
@@ -164,8 +177,12 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
if (data && typeof data === 'string' && data.trim()) {
|
||||
try {
|
||||
parsedData = JSON.parse(data)
|
||||
} catch (_e) {
|
||||
throw new Error('Invalid JSON data format')
|
||||
} catch (parseError) {
|
||||
// Provide more detailed error information
|
||||
const errorMsg = parseError instanceof Error ? parseError.message : 'Unknown JSON error'
|
||||
throw new Error(
|
||||
`Invalid JSON data format: ${errorMsg}. Please check your JSON syntax (e.g., strings must be quoted like "value").`
|
||||
)
|
||||
}
|
||||
} else if (data && typeof data === 'object') {
|
||||
parsedData = data
|
||||
|
||||
@@ -171,6 +171,9 @@ export interface SubBlockConfig {
|
||||
// Trigger-specific configuration
|
||||
availableTriggers?: string[] // List of trigger IDs available for this subblock
|
||||
triggerProvider?: string // Which provider's triggers to show
|
||||
// Declarative dependency hints for cross-field clearing or invalidation
|
||||
// Example: dependsOn: ['credential'] means this field should be cleared when credential changes
|
||||
dependsOn?: string[]
|
||||
}
|
||||
|
||||
// Main block definition
|
||||
|
||||
@@ -31,7 +31,7 @@ export const baseStyles = {
|
||||
},
|
||||
button: {
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'var(--brand-primary-hover-hex)',
|
||||
backgroundColor: '#802FFF',
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
@@ -42,7 +42,7 @@ export const baseStyles = {
|
||||
margin: '20px 0',
|
||||
},
|
||||
link: {
|
||||
color: 'var(--brand-primary-hover-hex)',
|
||||
color: '#802FFF',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
footer: {
|
||||
@@ -79,7 +79,7 @@ export const baseStyles = {
|
||||
width: '249px',
|
||||
},
|
||||
sectionCenter: {
|
||||
borderBottom: '1px solid var(--brand-primary-hover-hex)',
|
||||
borderBottom: '1px solid #802FFF',
|
||||
width: '102px',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} 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'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
@@ -27,6 +31,8 @@ interface BatchInvitationEmailProps {
|
||||
acceptUrl: string
|
||||
}
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
|
||||
const getPermissionLabel = (permission: string) => {
|
||||
switch (permission) {
|
||||
case 'admin':
|
||||
@@ -43,9 +49,9 @@ const getPermissionLabel = (permission: string) => {
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Team Admin (can manage team and billing)'
|
||||
return 'Admin'
|
||||
case 'member':
|
||||
return 'Team Member (billing access only)'
|
||||
return 'Member'
|
||||
default:
|
||||
return role
|
||||
}
|
||||
@@ -64,217 +70,101 @@ export const BatchInvitationEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
You've been invited to join {organizationName}
|
||||
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
|
||||
</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={logoContainer}>
|
||||
<Img
|
||||
src={brand.logoUrl || 'https://sim.ai/logo.png'}
|
||||
width='120'
|
||||
height='36'
|
||||
alt={brand.name}
|
||||
style={logo}
|
||||
/>
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>
|
||||
You've been invited to join {organizationName}
|
||||
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
|
||||
</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Heading style={h1}>You're invited to join {organizationName}!</Heading>
|
||||
|
||||
<Text style={text}>
|
||||
<strong>{inviterName}</strong> has invited you to join{' '}
|
||||
<strong>{organizationName}</strong> on Sim.
|
||||
</Text>
|
||||
|
||||
{/* Organization Invitation Details */}
|
||||
<Section style={invitationSection}>
|
||||
<Heading style={h2}>Team Access</Heading>
|
||||
<div style={roleCard}>
|
||||
<Text style={roleTitle}>Team Role: {getRoleLabel(organizationRole)}</Text>
|
||||
<Text style={roleDescription}>
|
||||
{organizationRole === 'admin'
|
||||
? "You'll be able to manage team members, billing, and workspace access."
|
||||
: "You'll have access to shared team billing and can be invited to workspaces."}
|
||||
</Text>
|
||||
</div>
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Workspace Invitations */}
|
||||
{hasWorkspaces && (
|
||||
<Section style={invitationSection}>
|
||||
<Heading style={h2}>
|
||||
Workspace Access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''})
|
||||
</Heading>
|
||||
<Text style={text}>You're also being invited to the following workspaces:</Text>
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> has invited you to join{' '}
|
||||
<strong>{organizationName}</strong> on Sim.
|
||||
</Text>
|
||||
|
||||
{workspaceInvitations.map((ws, index) => (
|
||||
<div key={ws.workspaceId} style={workspaceCard}>
|
||||
<Text style={workspaceName}>{ws.workspaceName}</Text>
|
||||
<Text style={workspacePermission}>{getPermissionLabel(ws.permission)}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{/* Team Role Information */}
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{organizationRole === 'admin'
|
||||
? "As a Team Admin, you'll be able to manage team members, billing, and workspace access."
|
||||
: "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."}
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={acceptUrl}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
{/* Workspace Invitations */}
|
||||
{hasWorkspaces && (
|
||||
<>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>
|
||||
Workspace Access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''}):
|
||||
</strong>
|
||||
</Text>
|
||||
{workspaceInvitations.map((ws) => (
|
||||
<Text
|
||||
key={ws.workspaceId}
|
||||
style={{ ...baseStyles.paragraph, marginLeft: '20px' }}
|
||||
>
|
||||
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
By accepting this invitation, you'll join {organizationName}
|
||||
{hasWorkspaces
|
||||
? ` and gain access to ${workspaceInvitations.length} workspace(s)`
|
||||
: ''}
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
This invitation will expire in 7 days. If you didn't expect this invitation, you can
|
||||
safely ignore this email.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={text}>
|
||||
By accepting this invitation, you'll join {organizationName}
|
||||
{hasWorkspaces ? ` and gain access to ${workspaceInvitations.length} workspace(s)` : ''}
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Hr style={hr} />
|
||||
|
||||
<Text style={footer}>
|
||||
If you have any questions, you can reach out to {inviterName} directly or contact our
|
||||
support team.
|
||||
</Text>
|
||||
|
||||
<Text style={footer}>
|
||||
This invitation will expire in 7 days. If you didn't expect this invitation, you can
|
||||
safely ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchInvitationEmail
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
}
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '20px 0 48px',
|
||||
marginBottom: '64px',
|
||||
}
|
||||
|
||||
const logoContainer = {
|
||||
margin: '32px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const logo = {
|
||||
margin: '0 auto',
|
||||
}
|
||||
|
||||
const h1 = {
|
||||
color: '#333',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '40px 0',
|
||||
padding: '0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const h2 = {
|
||||
color: '#333',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
margin: '24px 0 16px 0',
|
||||
padding: '0',
|
||||
}
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '26px',
|
||||
margin: '16px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
|
||||
const invitationSection = {
|
||||
margin: '32px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
|
||||
const roleCard = {
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
margin: '16px 0',
|
||||
}
|
||||
|
||||
const roleTitle = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0 0 8px 0',
|
||||
}
|
||||
|
||||
const roleDescription = {
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const workspaceCard = {
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 16px',
|
||||
margin: '8px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const workspaceName = {
|
||||
color: '#333',
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const workspacePermission = {
|
||||
color: '#6c757d',
|
||||
fontSize: '13px',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const buttonContainer = {
|
||||
margin: '32px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#007bff',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '12px 24px',
|
||||
margin: '0 auto',
|
||||
}
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e9ecef',
|
||||
margin: '32px 0',
|
||||
}
|
||||
|
||||
const footer = {
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
margin: '8px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
|
||||
136
apps/sim/components/emails/help-confirmation-email.tsx
Normal file
136
apps/sim/components/emails/help-confirmation-email.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
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'
|
||||
|
||||
interface HelpConfirmationEmailProps {
|
||||
userEmail?: string
|
||||
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
|
||||
attachmentCount?: number
|
||||
submittedDate?: Date
|
||||
}
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bug':
|
||||
return 'Bug Report'
|
||||
case 'feedback':
|
||||
return 'Feedback'
|
||||
case 'feature_request':
|
||||
return 'Feature Request'
|
||||
case 'other':
|
||||
return 'General Inquiry'
|
||||
default:
|
||||
return 'Request'
|
||||
}
|
||||
}
|
||||
|
||||
export const HelpConfirmationEmail = ({
|
||||
userEmail = '',
|
||||
type = 'other',
|
||||
attachmentCount = 0,
|
||||
submittedDate = new Date(),
|
||||
}: HelpConfirmationEmailProps) => {
|
||||
const brand = getBrandConfig()
|
||||
const typeLabel = getTypeLabel(type)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Your {typeLabel.toLowerCase()} has been received</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.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}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Thank you for your <strong>{typeLabel.toLowerCase()}</strong> submission. We've
|
||||
received your request and will get back to you as soon as possible.
|
||||
</Text>
|
||||
|
||||
{attachmentCount > 0 && (
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You attached{' '}
|
||||
<strong>
|
||||
{attachmentCount} image{attachmentCount > 1 ? 's' : ''}
|
||||
</strong>{' '}
|
||||
with your request.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We typically respond to{' '}
|
||||
{type === 'bug'
|
||||
? 'bug reports'
|
||||
: type === 'feature_request'
|
||||
? 'feature requests'
|
||||
: 'inquiries'}{' '}
|
||||
within a few hours. If you need immediate assistance, please don't hesitate to reach
|
||||
out to us directly.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {brand.name} Team
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
marginTop: '40px',
|
||||
textAlign: 'left',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} for your{' '}
|
||||
{typeLabel.toLowerCase()} submission from {userEmail}.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpConfirmationEmail
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './base-styles'
|
||||
export { BatchInvitationEmail } from './batch-invitation-email'
|
||||
export { default as EmailFooter } from './footer'
|
||||
export { HelpConfirmationEmail } from './help-confirmation-email'
|
||||
export { InvitationEmail } from './invitation-email'
|
||||
export { OTPVerificationEmail } from './otp-verification-email'
|
||||
export * from './render-email'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from '@react-email/components'
|
||||
import {
|
||||
BatchInvitationEmail,
|
||||
HelpConfirmationEmail,
|
||||
InvitationEmail,
|
||||
OTPVerificationEmail,
|
||||
ResetPasswordEmail,
|
||||
@@ -65,6 +66,21 @@ export async function renderBatchInvitationEmail(
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderHelpConfirmationEmail(
|
||||
userEmail: string,
|
||||
type: 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
attachmentCount = 0
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
HelpConfirmationEmail({
|
||||
userEmail,
|
||||
type,
|
||||
attachmentCount,
|
||||
submittedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getEmailSubject(
|
||||
type:
|
||||
| 'sign-in'
|
||||
@@ -73,6 +89,7 @@ export function getEmailSubject(
|
||||
| 'reset-password'
|
||||
| 'invitation'
|
||||
| 'batch-invitation'
|
||||
| 'help-confirmation'
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'sign-in':
|
||||
@@ -87,6 +104,8 @@ export function getEmailSubject(
|
||||
return "You've been invited to join a team on Sim"
|
||||
case 'batch-invitation':
|
||||
return "You've been invited to join a team and workspaces on Sim"
|
||||
case 'help-confirmation':
|
||||
return 'Your request has been received'
|
||||
default:
|
||||
return 'Sim'
|
||||
}
|
||||
|
||||
@@ -1326,5 +1326,59 @@ describe('AgentBlockHandler', () => {
|
||||
expect(requestBody.model).toBe('azure/gpt-4o')
|
||||
expect(requestBody.apiKey).toBe('test-azure-api-key')
|
||||
})
|
||||
|
||||
it('should pass GPT-5 specific parameters (reasoningEffort and verbosity) through the request pipeline', async () => {
|
||||
const inputs = {
|
||||
model: 'gpt-5',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
userPrompt: 'Hello!',
|
||||
apiKey: 'test-api-key',
|
||||
reasoningEffort: 'minimal',
|
||||
verbosity: 'high',
|
||||
temperature: 0.7,
|
||||
}
|
||||
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
|
||||
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
|
||||
// Check that GPT-5 parameters are included in the request
|
||||
expect(requestBody.reasoningEffort).toBe('minimal')
|
||||
expect(requestBody.verbosity).toBe('high')
|
||||
expect(requestBody.provider).toBe('openai')
|
||||
expect(requestBody.model).toBe('gpt-5')
|
||||
expect(requestBody.apiKey).toBe('test-api-key')
|
||||
})
|
||||
|
||||
it('should handle missing GPT-5 parameters gracefully', async () => {
|
||||
const inputs = {
|
||||
model: 'gpt-5',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
userPrompt: 'Hello!',
|
||||
apiKey: 'test-api-key',
|
||||
temperature: 0.7,
|
||||
// No reasoningEffort or verbosity provided
|
||||
}
|
||||
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await handler.execute(mockBlock, inputs, mockContext)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
|
||||
|
||||
const fetchCall = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(fetchCall[1].body)
|
||||
|
||||
// Check that GPT-5 parameters are undefined when not provided
|
||||
expect(requestBody.reasoningEffort).toBeUndefined()
|
||||
expect(requestBody.verbosity).toBeUndefined()
|
||||
expect(requestBody.provider).toBe('openai')
|
||||
expect(requestBody.model).toBe('gpt-5')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,6 +368,8 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
stream: streaming,
|
||||
messages,
|
||||
environmentVariables: context.environmentVariables || {},
|
||||
reasoningEffort: inputs.reasoningEffort,
|
||||
verbosity: inputs.verbosity,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AgentInputs {
|
||||
apiKey?: string
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
reasoningEffort?: string
|
||||
verbosity?: string
|
||||
}
|
||||
|
||||
export interface ToolInput {
|
||||
|
||||
@@ -209,6 +209,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
success: true,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { data: 'test result' },
|
||||
childTraceSpans: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,6 +249,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
success: true,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { nested: 'data' },
|
||||
childTraceSpans: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { Executor } from '@/executor'
|
||||
@@ -104,18 +105,17 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// Remove current execution from stack after completion
|
||||
WorkflowBlockHandler.executionStack.delete(executionId)
|
||||
|
||||
// Log execution completion
|
||||
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`)
|
||||
|
||||
// Map child workflow output to parent block output
|
||||
const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context)
|
||||
const mappedResult = this.mapChildOutputToParent(
|
||||
result,
|
||||
workflowId,
|
||||
childWorkflowName,
|
||||
duration
|
||||
duration,
|
||||
childTraceSpans
|
||||
)
|
||||
|
||||
// If the child workflow failed, throw an error to trigger proper error handling in the parent
|
||||
if ((mappedResult as any).success === false) {
|
||||
const childError = (mappedResult as any).error || 'Unknown error'
|
||||
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
|
||||
@@ -125,19 +125,13 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error executing child workflow ${workflowId}:`, error)
|
||||
|
||||
// Clean up execution stack in case of error
|
||||
const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}`
|
||||
WorkflowBlockHandler.executionStack.delete(executionId)
|
||||
|
||||
// Get workflow name for error reporting
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
const childWorkflowName = workflowMetadata?.name || workflowId
|
||||
|
||||
// Enhance error message with child workflow context
|
||||
const originalError = error.message || 'Unknown error'
|
||||
|
||||
// Check if error message already has child workflow context to avoid duplication
|
||||
if (originalError.startsWith('Error in child workflow')) {
|
||||
throw error // Re-throw as-is to avoid duplication
|
||||
}
|
||||
@@ -151,12 +145,9 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
*/
|
||||
private async loadChildWorkflow(workflowId: string) {
|
||||
try {
|
||||
// Fetch workflow from API with internal authentication header
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add internal auth header for server-side calls
|
||||
if (typeof window === 'undefined') {
|
||||
const token = await generateInternalToken()
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
@@ -182,16 +173,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
|
||||
|
||||
// Extract the workflow state (API returns normalized data in state field)
|
||||
const workflowState = workflowData.state
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
logger.error(`Child workflow ${workflowId} has invalid state`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use blocks directly since API returns data from normalized tables
|
||||
const serializedWorkflow = this.serializer.serializeWorkflow(
|
||||
workflowState.blocks,
|
||||
workflowState.edges || [],
|
||||
@@ -222,17 +209,101 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps child workflow output to parent block output format
|
||||
* Captures and transforms child workflow logs into trace spans
|
||||
*/
|
||||
private captureChildWorkflowLogs(
|
||||
childResult: any,
|
||||
childWorkflowName: string,
|
||||
parentContext: ExecutionContext
|
||||
): any[] {
|
||||
try {
|
||||
if (!childResult.logs || !Array.isArray(childResult.logs)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(childResult)
|
||||
|
||||
if (!traceSpans || traceSpans.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const transformedSpans = traceSpans.map((span: any) => {
|
||||
return this.transformSpanForChildWorkflow(span, childWorkflowName)
|
||||
})
|
||||
|
||||
return transformedSpans
|
||||
} catch (error) {
|
||||
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms trace span for child workflow context
|
||||
*/
|
||||
private transformSpanForChildWorkflow(span: any, childWorkflowName: string): any {
|
||||
const transformedSpan = {
|
||||
...span,
|
||||
name: this.cleanChildSpanName(span.name, childWorkflowName),
|
||||
metadata: {
|
||||
...span.metadata,
|
||||
isFromChildWorkflow: true,
|
||||
childWorkflowName,
|
||||
},
|
||||
}
|
||||
|
||||
if (span.children && Array.isArray(span.children)) {
|
||||
transformedSpan.children = span.children.map((childSpan: any) =>
|
||||
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
|
||||
)
|
||||
}
|
||||
|
||||
if (span.output?.childTraceSpans) {
|
||||
transformedSpan.output = {
|
||||
...transformedSpan.output,
|
||||
childTraceSpans: span.output.childTraceSpans,
|
||||
}
|
||||
}
|
||||
|
||||
return transformedSpan
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up child span names for readability
|
||||
*/
|
||||
private cleanChildSpanName(spanName: string, childWorkflowName: string): string {
|
||||
if (spanName.includes(`${childWorkflowName}:`)) {
|
||||
const cleanName = spanName.replace(`${childWorkflowName}:`, '').trim()
|
||||
|
||||
if (cleanName === 'Workflow Execution') {
|
||||
return `${childWorkflowName} workflow`
|
||||
}
|
||||
|
||||
if (cleanName.startsWith('Agent ')) {
|
||||
return `${cleanName}`
|
||||
}
|
||||
|
||||
return `${cleanName}`
|
||||
}
|
||||
|
||||
if (spanName === 'Workflow Execution') {
|
||||
return `${childWorkflowName} workflow`
|
||||
}
|
||||
|
||||
return `${spanName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps child workflow output to parent block output
|
||||
*/
|
||||
private mapChildOutputToParent(
|
||||
childResult: any,
|
||||
childWorkflowId: string,
|
||||
childWorkflowName: string,
|
||||
duration: number
|
||||
duration: number,
|
||||
childTraceSpans?: any[]
|
||||
): BlockOutput {
|
||||
const success = childResult.success !== false
|
||||
|
||||
// If child workflow failed, return minimal output
|
||||
if (!success) {
|
||||
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
||||
return {
|
||||
@@ -241,18 +312,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
error: childResult.error || 'Child workflow execution failed',
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
// Extract the actual result content from the flattened structure
|
||||
let result = childResult
|
||||
if (childResult?.output) {
|
||||
result = childResult.output
|
||||
}
|
||||
|
||||
// Return a properly structured response with all required fields
|
||||
return {
|
||||
success: true,
|
||||
childWorkflowName,
|
||||
result,
|
||||
childTraceSpans: childTraceSpans || [],
|
||||
} as Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BlockPathCalculator } from '@/lib/block-path-calculator'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import {
|
||||
@@ -225,7 +226,7 @@ export class Executor {
|
||||
|
||||
let hasMoreLayers = true
|
||||
let iteration = 0
|
||||
const maxIterations = 100 // Safety limit for infinite loops
|
||||
const maxIterations = 500 // Safety limit for infinite loops
|
||||
|
||||
while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) {
|
||||
const nextLayer = this.getNextExecutionLayer(context)
|
||||
@@ -770,7 +771,7 @@ export class Executor {
|
||||
// Get the field value from workflow input if available
|
||||
// First try to access via input.field, then directly from field
|
||||
// This handles both input formats: { input: { field: value } } and { field: value }
|
||||
const inputValue =
|
||||
let inputValue =
|
||||
this.workflowInput?.input?.[field.name] !== undefined
|
||||
? this.workflowInput.input[field.name] // Try to get from input.field
|
||||
: this.workflowInput?.[field.name] // Fallback to direct field access
|
||||
@@ -780,13 +781,25 @@ export class Executor {
|
||||
inputValue !== undefined ? JSON.stringify(inputValue) : 'undefined'
|
||||
)
|
||||
|
||||
// Convert the value to the appropriate type
|
||||
if (inputValue === undefined || inputValue === null) {
|
||||
if (Object.hasOwn(field, 'value')) {
|
||||
inputValue = (field as any).value
|
||||
}
|
||||
}
|
||||
|
||||
let typedValue = inputValue
|
||||
if (inputValue !== undefined) {
|
||||
if (field.type === 'number' && typeof inputValue !== 'number') {
|
||||
typedValue = Number(inputValue)
|
||||
if (inputValue !== undefined && inputValue !== null) {
|
||||
if (field.type === 'string' && typeof inputValue !== 'string') {
|
||||
typedValue = String(inputValue)
|
||||
} else if (field.type === 'number' && typeof inputValue !== 'number') {
|
||||
const num = Number(inputValue)
|
||||
typedValue = Number.isNaN(num) ? inputValue : num
|
||||
} else if (field.type === 'boolean' && typeof inputValue !== 'boolean') {
|
||||
typedValue = inputValue === 'true' || inputValue === true
|
||||
typedValue =
|
||||
inputValue === 'true' ||
|
||||
inputValue === true ||
|
||||
inputValue === 1 ||
|
||||
inputValue === '1'
|
||||
} else if (
|
||||
(field.type === 'object' || field.type === 'array') &&
|
||||
typeof inputValue === 'string'
|
||||
@@ -1510,6 +1523,9 @@ export class Executor {
|
||||
blockLog.durationMs = Math.round(executionTime)
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
|
||||
// Handle child workflow logs integration
|
||||
this.integrateChildWorkflowLogs(block, output)
|
||||
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
@@ -1617,6 +1633,9 @@ export class Executor {
|
||||
blockLog.durationMs = Math.round(executionTime)
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
|
||||
// Handle child workflow logs integration
|
||||
this.integrateChildWorkflowLogs(block, output)
|
||||
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
@@ -2003,4 +2022,22 @@ export class Executor {
|
||||
context.blockLogs.push(starterBlockLog)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves child workflow trace spans for proper nesting
|
||||
*/
|
||||
private integrateChildWorkflowLogs(block: SerializedBlock, output: NormalizedBlockOutput): void {
|
||||
if (block.metadata?.id !== BlockType.WORKFLOW) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!output || typeof output !== 'object' || !output.childTraceSpans) {
|
||||
return
|
||||
}
|
||||
|
||||
const childTraceSpans = output.childTraceSpans as TraceSpan[]
|
||||
if (!Array.isArray(childTraceSpans) || childTraceSpans.length === 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +804,7 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
const collaborativeSetSubblockValue = useCallback(
|
||||
(blockId: string, subblockId: string, value: any) => {
|
||||
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
|
||||
if (isApplyingRemoteChange.current) return
|
||||
|
||||
// Skip socket operations when in diff mode
|
||||
@@ -840,6 +840,28 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
// Apply locally first (immediate UI feedback)
|
||||
subBlockStore.setValue(blockId, subblockId, value)
|
||||
|
||||
// Declarative clearing: clear sub-blocks that depend on this subblockId
|
||||
try {
|
||||
const visited = options?._visited || new Set<string>()
|
||||
if (visited.has(subblockId)) return
|
||||
visited.add(subblockId)
|
||||
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
|
||||
const blockConfig = blockType ? getBlock(blockType) : null
|
||||
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
|
||||
const dependents = blockConfig.subBlocks.filter(
|
||||
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
|
||||
)
|
||||
for (const dep of dependents) {
|
||||
// Skip clearing if the dependent is the same field
|
||||
if (!dep?.id || dep.id === subblockId) continue
|
||||
// Cascade using the same collaborative path so it emits and further cascades
|
||||
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; do not block on clearing
|
||||
}
|
||||
},
|
||||
[
|
||||
subBlockStore,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'better-auth/plugins'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { headers } from 'next/headers'
|
||||
import { Resend } from 'resend'
|
||||
import Stripe from 'stripe'
|
||||
import {
|
||||
getEmailSubject,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from '@/components/emails/render-email'
|
||||
import { getBaseURL } from '@/lib/auth-client'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { isBillingEnabled, isProd } from '@/lib/environment'
|
||||
@@ -45,22 +45,6 @@ if (validStripeKey) {
|
||||
})
|
||||
}
|
||||
|
||||
// If there is no resend key, it might be a local dev environment
|
||||
// In that case, we don't want to send emails and just log them
|
||||
const validResendAPIKEY =
|
||||
env.RESEND_API_KEY && env.RESEND_API_KEY.trim() !== '' && env.RESEND_API_KEY !== 'placeholder'
|
||||
|
||||
const resend = validResendAPIKEY
|
||||
? new Resend(env.RESEND_API_KEY)
|
||||
: {
|
||||
emails: {
|
||||
send: async (...args: any[]) => {
|
||||
logger.info('Email would have been sent in production. Details:', args)
|
||||
return { id: 'local-dev-mode' }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: getBaseURL(),
|
||||
trustedOrigins: [
|
||||
@@ -165,15 +149,16 @@ export const auth = betterAuth({
|
||||
|
||||
const html = await renderPasswordResetEmail(username, url)
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: `Sim <team@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
const result = await sendEmail({
|
||||
to: user.email,
|
||||
subject: getEmailSubject('reset-password'),
|
||||
html,
|
||||
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Failed to send reset password email')
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to send reset password email: ${result.message}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -252,8 +237,19 @@ export const auth = betterAuth({
|
||||
)
|
||||
}
|
||||
|
||||
// In development with no RESEND_API_KEY, log verification code
|
||||
if (!validResendAPIKEY) {
|
||||
const html = await renderOTPEmail(data.otp, data.email, data.type)
|
||||
|
||||
// Send email via consolidated mailer (supports Resend, Azure, or logging fallback)
|
||||
const result = await sendEmail({
|
||||
to: data.email,
|
||||
subject: getEmailSubject(data.type),
|
||||
html,
|
||||
from: `onboarding@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
// If no email service is configured, log verification code for development
|
||||
if (!result.success && result.message.includes('no email service configured')) {
|
||||
logger.info('🔑 VERIFICATION CODE FOR LOGIN/SIGNUP', {
|
||||
email: data.email,
|
||||
otp: data.otp,
|
||||
@@ -263,18 +259,8 @@ export const auth = betterAuth({
|
||||
return
|
||||
}
|
||||
|
||||
const html = await renderOTPEmail(data.otp, data.email, data.type)
|
||||
|
||||
// In production, send an actual email
|
||||
const result = await resend.emails.send({
|
||||
from: `Sim <onboarding@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
to: data.email,
|
||||
subject: getEmailSubject(data.type),
|
||||
html,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Failed to send verification code')
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to send verification code: ${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending verification code:', {
|
||||
@@ -470,7 +456,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-teams`,
|
||||
},
|
||||
@@ -486,7 +471,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-excel`,
|
||||
},
|
||||
@@ -509,7 +493,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-planner`,
|
||||
},
|
||||
@@ -534,7 +517,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/outlook`,
|
||||
},
|
||||
@@ -550,7 +532,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/onedrive`,
|
||||
},
|
||||
@@ -573,7 +554,6 @@ export const auth = betterAuth({
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
pkce: true,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/sharepoint`,
|
||||
},
|
||||
@@ -1284,109 +1264,30 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
// Auto-create organization for team plan purchases
|
||||
if (subscription.plan === 'team') {
|
||||
try {
|
||||
// Get the user who purchased the subscription
|
||||
const user = await db
|
||||
.select()
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (user.length > 0) {
|
||||
const currentUser = user[0]
|
||||
|
||||
// Create organization for the team
|
||||
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
|
||||
|
||||
// Create a separate Stripe customer for the organization
|
||||
let orgStripeCustomerId: string | null = null
|
||||
if (stripeClient) {
|
||||
try {
|
||||
const orgStripeCustomer = await stripeClient.customers.create({
|
||||
name: `${currentUser.name || 'User'}'s Team`,
|
||||
email: currentUser.email,
|
||||
metadata: {
|
||||
organizationId: orgId,
|
||||
type: 'organization',
|
||||
},
|
||||
})
|
||||
orgStripeCustomerId = orgStripeCustomer.id
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Stripe customer for organization', {
|
||||
organizationId: orgId,
|
||||
error,
|
||||
})
|
||||
// Continue without Stripe customer - can be created later
|
||||
}
|
||||
}
|
||||
|
||||
const newOrg = await db
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: `${currentUser.name || 'User'}'s Team`,
|
||||
slug: orgSlug,
|
||||
metadata: orgStripeCustomerId
|
||||
? { stripeCustomerId: orgStripeCustomerId }
|
||||
: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Add the user as owner of the organization
|
||||
await db.insert(schema.member).values({
|
||||
id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
userId: currentUser.id,
|
||||
organizationId: orgId,
|
||||
role: 'owner',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Update the subscription to reference the organization instead of the user
|
||||
await db
|
||||
.update(schema.subscription)
|
||||
.set({ referenceId: orgId })
|
||||
.where(eq(schema.subscription.id, subscription.id))
|
||||
|
||||
// Update the session to set the new organization as active
|
||||
await db
|
||||
.update(schema.session)
|
||||
.set({ activeOrganizationId: orgId })
|
||||
.where(eq(schema.session.userId, currentUser.id))
|
||||
|
||||
logger.info('Auto-created organization for team subscription', {
|
||||
organizationId: orgId,
|
||||
userId: currentUser.id,
|
||||
subscriptionId: subscription.id,
|
||||
orgName: `${currentUser.name || 'User'}'s Team`,
|
||||
})
|
||||
|
||||
// Update referenceId for usage limit sync
|
||||
subscription.referenceId = orgId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-create organization for team subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
try {
|
||||
const { handleTeamPlanOrganization } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await handleTeamPlanOrganization(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle team plan organization creation', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Sync usage limits and initialize billing period for the user/organization
|
||||
// Initialize billing period and sync usage limits
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
const { initializeBillingPeriod } = await import(
|
||||
'@/lib/billing/core/billing-periods'
|
||||
)
|
||||
const { syncSubscriptionUsageLimits } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription creation', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
// Sync usage limits for user or organization members
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
// Initialize billing period for new subscription using Stripe dates
|
||||
if (subscription.plan !== 'free') {
|
||||
@@ -1423,15 +1324,29 @@ export const auth = betterAuth({
|
||||
logger.info('Subscription updated', {
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
|
||||
// Auto-create organization for team plan upgrades (free -> team)
|
||||
try {
|
||||
const { handleTeamPlanOrganization } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await handleTeamPlanOrganization(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle team plan organization creation on update', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Sync usage limits for the user/organization
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription update', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
const { syncSubscriptionUsageLimits } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits after subscription update', {
|
||||
referenceId: subscription.referenceId,
|
||||
@@ -1527,12 +1442,17 @@ export const auth = betterAuth({
|
||||
invitation.email
|
||||
)
|
||||
|
||||
await resend.emails.send({
|
||||
from: `Sim <team@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
const result = await sendEmail({
|
||||
to: invitation.email,
|
||||
subject: `${inviterName} has invited you to join ${organization.name} on Sim`,
|
||||
html,
|
||||
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to send organization invitation email:', result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending invitation email', { error })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,24 @@ import { member, organization, subscription, user, userStats } from '@/db/schema
|
||||
|
||||
const logger = createLogger('Billing')
|
||||
|
||||
/**
|
||||
* Get organization subscription directly by organization ID
|
||||
*/
|
||||
export async function getOrganizationSubscription(organizationId: string) {
|
||||
try {
|
||||
const orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return orgSubs.length > 0 ? orgSubs[0] : null
|
||||
} catch (error) {
|
||||
logger.error('Error getting organization subscription', { error, organizationId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface BillingResult {
|
||||
success: boolean
|
||||
chargedAmount?: number
|
||||
@@ -89,15 +107,43 @@ async function getStripeCustomerId(referenceId: string): Promise<string | null>
|
||||
.where(eq(organization.id, referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (orgRecord.length > 0 && orgRecord[0].metadata) {
|
||||
const metadata =
|
||||
typeof orgRecord[0].metadata === 'string'
|
||||
? JSON.parse(orgRecord[0].metadata)
|
||||
: orgRecord[0].metadata
|
||||
if (orgRecord.length > 0) {
|
||||
// First, check if organization has its own Stripe customer (legacy support)
|
||||
if (orgRecord[0].metadata) {
|
||||
const metadata =
|
||||
typeof orgRecord[0].metadata === 'string'
|
||||
? JSON.parse(orgRecord[0].metadata)
|
||||
: orgRecord[0].metadata
|
||||
|
||||
if (metadata?.stripeCustomerId) {
|
||||
return metadata.stripeCustomerId
|
||||
if (metadata?.stripeCustomerId) {
|
||||
return metadata.stripeCustomerId
|
||||
}
|
||||
}
|
||||
|
||||
// If organization has no Stripe customer, use the owner's customer
|
||||
// This is our new pattern: subscriptions stay with user, referenceId = orgId
|
||||
const ownerRecord = await db
|
||||
.select({
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
userId: user.id,
|
||||
})
|
||||
.from(user)
|
||||
.innerJoin(member, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (ownerRecord.length > 0 && ownerRecord[0].stripeCustomerId) {
|
||||
logger.debug('Using organization owner Stripe customer for billing', {
|
||||
organizationId: referenceId,
|
||||
ownerId: ownerRecord[0].userId,
|
||||
stripeCustomerId: ownerRecord[0].stripeCustomerId,
|
||||
})
|
||||
return ownerRecord[0].stripeCustomerId
|
||||
}
|
||||
|
||||
logger.warn('No Stripe customer found for organization or its owner', {
|
||||
organizationId: referenceId,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -431,8 +477,8 @@ export async function processOrganizationOverageBilling(
|
||||
organizationId: string
|
||||
): Promise<BillingResult> {
|
||||
try {
|
||||
// Get organization subscription
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
// Get organization subscription directly (referenceId = organizationId)
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscription || !['team', 'enterprise'].includes(subscription.plan)) {
|
||||
logger.warn('No team/enterprise subscription found for organization', { organizationId })
|
||||
@@ -759,7 +805,9 @@ export async function getSimplifiedBillingSummary(
|
||||
try {
|
||||
// Get subscription and usage data upfront
|
||||
const [subscription, usageData] = await Promise.all([
|
||||
getHighestPrioritySubscription(organizationId || userId),
|
||||
organizationId
|
||||
? getOrganizationSubscription(organizationId)
|
||||
: getHighestPrioritySubscription(userId),
|
||||
getUserUsageData(userId),
|
||||
])
|
||||
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, user, userStats } from '@/db/schema'
|
||||
import { member, organization, subscription, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationBilling')
|
||||
|
||||
/**
|
||||
* Get organization subscription directly by organization ID
|
||||
* This is for our new pattern where referenceId = organizationId
|
||||
*/
|
||||
async function getOrganizationSubscription(organizationId: string) {
|
||||
try {
|
||||
const orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return orgSubs.length > 0 ? orgSubs[0] : null
|
||||
} catch (error) {
|
||||
logger.error('Error getting organization subscription', { error, organizationId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface OrganizationUsageData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
@@ -57,8 +75,8 @@ export async function getOrganizationBillingData(
|
||||
|
||||
const organizationData = orgRecord[0]
|
||||
|
||||
// Get organization subscription
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
// Get organization subscription directly (referenceId = organizationId)
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('No subscription found for organization', { organizationId })
|
||||
@@ -191,7 +209,7 @@ export async function updateMemberUsageLimit(
|
||||
}
|
||||
|
||||
// Get organization subscription to validate limit
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
if (!subscription) {
|
||||
throw new Error('No active subscription found')
|
||||
}
|
||||
|
||||
181
apps/sim/lib/billing/team-management.ts
Normal file
181
apps/sim/lib/billing/team-management.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, session, subscription, user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
type SubscriptionData = {
|
||||
id: string
|
||||
plan: string
|
||||
referenceId: string
|
||||
status: string
|
||||
seats?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-create organization for team plan subscriptions
|
||||
*/
|
||||
export async function handleTeamPlanOrganization(
|
||||
subscriptionData: SubscriptionData
|
||||
): Promise<void> {
|
||||
if (subscriptionData.plan !== 'team') return
|
||||
|
||||
try {
|
||||
// For team subscriptions, referenceId should be the user ID initially
|
||||
// But if the organization has already been created, it might be the org ID
|
||||
let userId: string = subscriptionData.referenceId
|
||||
let currentUser: any = null
|
||||
|
||||
// First try to get user directly (most common case)
|
||||
const users = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, subscriptionData.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0) {
|
||||
currentUser = users[0]
|
||||
userId = currentUser.id
|
||||
} else {
|
||||
// If referenceId is not a user ID, it might be an organization ID
|
||||
// In that case, the organization already exists, so we should skip
|
||||
const existingOrg = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscriptionData.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrg.length > 0) {
|
||||
logger.info('Organization already exists for team subscription, skipping creation', {
|
||||
organizationId: subscriptionData.referenceId,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('User not found for team subscription and no existing organization', {
|
||||
referenceId: subscriptionData.referenceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already has an organization membership
|
||||
const existingMember = await db.select().from(member).where(eq(member.userId, userId)).limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
logger.info('User already has organization membership, skipping auto-creation', {
|
||||
userId,
|
||||
existingOrgId: existingMember[0].organizationId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const orgName = `${currentUser.name || 'User'}'s Team`
|
||||
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
|
||||
|
||||
// Create organization directly in database
|
||||
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
|
||||
const [createdOrg] = await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (!createdOrg) {
|
||||
throw new Error('Failed to create organization in database')
|
||||
}
|
||||
|
||||
// Add the user as admin of the organization (owner role for full control)
|
||||
await db.insert(member).values({
|
||||
id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
userId: currentUser.id,
|
||||
organizationId: orgId,
|
||||
role: 'owner', // Owner gives full admin privileges
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Update the subscription to reference the organization instead of the user
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: orgId })
|
||||
.where(eq(subscription.id, subscriptionData.id))
|
||||
|
||||
// Update the user's session to set the new organization as active
|
||||
await db
|
||||
.update(session)
|
||||
.set({ activeOrganizationId: orgId })
|
||||
.where(eq(session.userId, currentUser.id))
|
||||
|
||||
logger.info('Auto-created organization for team subscription', {
|
||||
organizationId: orgId,
|
||||
userId: currentUser.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
orgName,
|
||||
userRole: 'owner',
|
||||
})
|
||||
|
||||
// Update subscription object for subsequent logic
|
||||
subscriptionData.referenceId = orgId
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-create organization for team subscription', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync usage limits for user or organization
|
||||
* Handles the complexity of determining whether to sync for user ID or org members
|
||||
*/
|
||||
export async function syncSubscriptionUsageLimits(
|
||||
subscriptionData: SubscriptionData
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
|
||||
// For team plans, the referenceId is now an organization ID
|
||||
// We need to sync limits for the organization members
|
||||
if (subscriptionData.plan === 'team') {
|
||||
// Get all members of the organization
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, subscriptionData.referenceId))
|
||||
|
||||
// Sync usage limits for each member
|
||||
for (const orgMember of orgMembers) {
|
||||
await syncUsageLimitsFromSubscription(orgMember.userId)
|
||||
}
|
||||
|
||||
logger.info('Synced usage limits for team organization members', {
|
||||
organizationId: subscriptionData.referenceId,
|
||||
memberCount: orgMembers.length,
|
||||
})
|
||||
} else {
|
||||
// For non-team plans, referenceId is the user ID
|
||||
await syncUsageLimitsFromSubscription(subscriptionData.referenceId)
|
||||
logger.info('Synced usage limits for user', {
|
||||
userId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync subscription usage limits', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user