diff --git a/.github/workflows/trigger-deploy.yml b/.github/workflows/trigger-deploy.yml new file mode 100644 index 0000000000..4bc714593d --- /dev/null +++ b/.github/workflows/trigger-deploy.yml @@ -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 + diff --git a/apps/docs/content/docs/tools/microsoft_excel.mdx b/apps/docs/content/docs/tools/microsoft_excel.mdx index 38b37827e6..4b4d0f1d7d 100644 --- a/apps/docs/content/docs/tools/microsoft_excel.mdx +++ b/apps/docs/content/docs/tools/microsoft_excel.mdx @@ -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 | diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 788d1f7d7d..f6416ef010 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -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) { diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index b9f31c2334..666e20a094 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -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`) diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index abd6ae29a5..23d4ca4a0e 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -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 `, + const emailResult = await sendEmail({ to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`], subject: `[${type.toUpperCase()}] ${subject}`, - replyTo: email, text: emailText, + from: `${env.SENDER_NAME || 'Sim'} `, + 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 `, + 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'} `, 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 } ) } diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index a3278d0b76..91171d713c 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -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' diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 0f8937372d..3fcd04db76 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -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]) + }) + }) }) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index 151054ab72..7a72e2703d 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -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 { - 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, embedding: any) { return Object.entries(filters).map(([key, value]) => { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 24d74a8897..0c5e84e637 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -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' + ) + }) }) }) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 448f6f53d1..df85c67df1 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -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 @@ -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 { - 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 diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 79dbbc87a7..17d37a4f38 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -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 diff --git a/apps/sim/app/api/tools/jira/issue/route.ts b/apps/sim/app/api/tools/jira/issue/route.ts index 3a8616a4c9..1aae5c1fad 100644 --- a/apps/sim/app/api/tools/jira/issue/route.ts +++ b/apps/sim/app/api/tools/jira/issue/route.ts @@ -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 { diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index 351546d62a..9e89fa1cd1 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -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 { diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts index d638499d88..da2ce3aaae 100644 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ b/apps/sim/app/api/tools/jira/projects/route.ts @@ -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 { diff --git a/apps/sim/app/api/tools/jira/update/route.ts b/apps/sim/app/api/tools/jira/update/route.ts index a3394bc17b..0657bb57fc 100644 --- a/apps/sim/app/api/tools/jira/update/route.ts +++ b/apps/sim/app/api/tools/jira/update/route.ts @@ -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 { diff --git a/apps/sim/app/api/tools/jira/write/route.ts b/apps/sim/app/api/tools/jira/write/route.ts index dedbe8a1ad..6cbfdfff01 100644 --- a/apps/sim/app/api/tools/jira/write/route.ts +++ b/apps/sim/app/api/tools/jira/write/route.ts @@ -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 { diff --git a/apps/sim/app/api/wand-generate/route.ts b/apps/sim/app/api/wand-generate/route.ts index d7eeba5be0..a7bee44f90 100644 --- a/apps/sim/app/api/wand-generate/route.ts +++ b/apps/sim/app/api/wand-generate/route.ts @@ -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 + +const useWandAzure = azureApiKey && azureEndpoint && azureApiVersion -if (!env.OPENAI_API_KEY) { - logger.warn('OPENAI_API_KEY not found. Wand generation API will not function.') +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( diff --git a/apps/sim/app/api/webhooks/poll/gmail/route.ts b/apps/sim/app/api/webhooks/poll/gmail/route.ts index 304bf7cf41..c6fc45412d 100644 --- a/apps/sim/app/api/webhooks/poll/gmail/route.ts +++ b/apps/sim/app/api/webhooks/poll/gmail/route.ts @@ -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 diff --git a/apps/sim/app/api/webhooks/poll/outlook/route.ts b/apps/sim/app/api/webhooks/poll/outlook/route.ts index d8163f8ee1..dbe01b8786 100644 --- a/apps/sim/app/api/webhooks/poll/outlook/route.ts +++ b/apps/sim/app/api/webhooks/poll/outlook/route.ts @@ -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 diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 7bda24dec3..d7a48dda19 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -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' }), }, diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index a5a7c61782..21e14211fb 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -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' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 1fee2a4c76..72147e9702 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -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' diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 6b88f299fd..aacd9a716e 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -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'} ` 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 diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 3f966a8bbc..6afd37f00a 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -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 */} - - + @@ -107,16 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - - {children} - {isHosted && ( - <> - - - - )} - + + + + {children} + {isHosted && ( + <> + + + + )} + + ) diff --git a/apps/sim/app/theme-provider.tsx b/apps/sim/app/theme-provider.tsx new file mode 100644 index 0000000000..d5a7fd3382 --- /dev/null +++ b/apps/sim/app/theme-provider.tsx @@ -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 ( + + {children} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx b/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx index d05d87c039..3885c80c0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx @@ -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(({ children }) => { return ( - + <> + {children} - + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/settings-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/settings-loader.tsx new file mode 100644 index 0000000000..40588ba43a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/settings-loader.tsx @@ -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 +} diff --git a/apps/sim/app/workspace/[workspaceId]/providers/theme-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/theme-provider.tsx deleted file mode 100644 index 98e9486d22..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/providers/theme-provider.tsx +++ /dev/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 -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx index 108b3bcb34..170b0a476c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx @@ -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(blockId, subBlockId) const [storeInitialized, setStoreInitialized] = useState(false) @@ -281,7 +283,7 @@ export function Dropdown({ {/* Dropdown */} {open && ( -
+
(blockId, subBlockId) - const [tagDropdownStates, setTagDropdownStates] = useState< - Record< - string, - { - visible: boolean - cursorPosition: number - } - > - >({}) const [dragHighlight, setDragHighlight] = useState>({}) - const valueInputRefs = useRef>({}) + const valueInputRefs = useRef>({}) + const [localValues, setLocalValues] = useState>({}) // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue const fields: Field[] = value || [] + useEffect(() => { + const initial: Record = {} + ;(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) + setLocalValues((prev) => ({ ...prev, [fieldId]: newValue })) + } - setTagDropdownStates((prev) => ({ - ...prev, - [fieldId]: { - visible: shouldShow.show, - cursorPosition, - }, - })) + // Value normalization: keep it simple for string types - updateField(fieldId, 'value', newValue) - } + const handleValueInputBlur = (field: Field) => { + if (isPreview || disabled) return - const handleTagSelect = (fieldId: string, newValue: string) => { - updateField(fieldId, 'value', newValue) - setTagDropdownStates((prev) => ({ - ...prev, - [fieldId]: { ...prev[fieldId], visible: false }, - })) - } + const inputEl = valueInputRefs.current[field.id] + if (!inputEl) return - const handleTagDropdownClose = (fieldId: string) => { - setTagDropdownStates((prev) => ({ - ...prev, - [fieldId]: { ...prev[fieldId], visible: false }, - })) + 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({
- { - 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' ? ( + + ) : field.type === 'object' || field.type === 'array' ? ( +