Compare commits

...

47 Commits

Author SHA1 Message Date
Waleed Latif
991f0442e9 v0.3.36: workflow block logs, whitelabeling configurability, session provider 2025-08-21 21:44:28 -07:00
Waleed Latif
2ebfb576ae fix(day-picker): remove unused react-day-picker (#1094) 2025-08-21 21:29:20 -07:00
Vikhyath Mondreti
11a7be54f2 fix circular dependsOn for Jira manualIssueKey 2025-08-21 21:21:19 -07:00
Vikhyath Mondreti
f5219d03c3 fix(ms-oauth): oauth edge cases (#1093) 2025-08-21 21:19:11 -07:00
Waleed Latif
f0643e01b4 fix(logs): make child workflow span errors the same as root level workflow errors (#1092) 2025-08-21 21:17:09 -07:00
Adam Gough
77b0c5b9ed Fix(excel-range): fixed excel range (#1088)
* added auto range

* lint

* removed any

* utils file

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-21 20:04:20 -07:00
Adam Gough
9dbd44e555 fix(webhook-payloads): fixed the variable resolution in webhooks (#1019)
* telegram webhook fix

* changed payloads

* test

* test

* test

* test

* fix github dropdown

* test

* reverted github changes

* fixed github var

* test

* bun run lint

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test

* test push

* test

* bun run lint

* edited airtable payload and webhook deletion

* Revert bun.lock and package.json to upstream/staging

* cleaned up

* test

* test

* resolving more cmments

* resolved comments, updated trigger

* cleaned up, resolved comments

* test

* test

* lint

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-21 20:03:04 -07:00
Waleed Latif
9ea9f2d52e improvement(log-level): make log level configurable via envvar (#1091) 2025-08-21 19:40:47 -07:00
Waleed Latif
4cd707fadb improvement(emails): fixed email subjects to use provided brand name (#1090)
* improvement(emails): fixed email subjects to use provided brand name

* update manifest to use dynamic background & theme color
2025-08-21 19:34:05 -07:00
Waleed Latif
f0b07428bc feat(theme): added custom envvars for themes (#1089)
* feat(theme): added custom envvars for themes

* add regec
2025-08-21 19:27:56 -07:00
Vikhyath Mondreti
8c9e182e10 fix(infinite-get-session): pass session once per tree using session provider + multiple fixes (#1085)
* fix(infinite-get-session): pass session using session provider

* prevent auto refetch

* fix typing:

* fix types

* fix

* fix oauth token for microsoft file selector

* fix start block required error
2025-08-21 18:45:15 -07:00
Waleed Latif
33dd59f7a7 fix(db-consts): make the migrations image fully standalone by adding db consts (#1087) 2025-08-21 17:25:35 -07:00
Waleed Latif
53ee9f99db fix(templates): added option to delete/keep templates when deleting workspace, updated template modal, sidebar code cleanup (#1086)
* feat(templates): added in the ability to keep/remove templates when deleting workspace

* code cleanup in sidebar

* add the ability to edit existing templates

* updated template modal

* fix build

* revert bun.lock

* add template logic to workflow deletion as well

* add ability to delete templates

* add owner/admin enforcemnet to modify or delete templates
2025-08-21 17:11:22 -07:00
Vikhyath Mondreti
0f2a125eae improvement(block-error-logs): workflow in workflow (#1084)
* improvement(add-block-logs): workflow in workflow

* fix lint
2025-08-21 15:01:30 -07:00
Waleed Latif
e107363ea7 v0.3.35: migrations, custom email address support 2025-08-21 12:36:51 -07:00
Waleed Latif
7e364a7977 fix(emails): remove unused useCustomFromFormat param (#1082)
* fix(mailer): remove unused useCustomFormat

* bun.lock changes
2025-08-21 12:09:03 -07:00
Waleed Latif
35a37d8b45 fix(acs): added FROM_EMAIL_ADDRESS envvar for ACS (#1081)
* fix: clear Docker build cache to use correct Next.js version

* fix(mailer): add FROM_EMAIL_ADDRESS envvar for ACS

* bun.lock

* added tests
2025-08-21 11:57:44 -07:00
Vikhyath Mondreti
2b52d88cee fix(migrations): add missing migration for document table (#1080)
* fix(migrations): add missing migration for document table

* add newline at end of file
2025-08-21 11:48:54 -07:00
Waleed Latif
abad3620a3 fix(build): clear docker build cache to use correct Next.js version 2025-08-21 01:43:45 -07:00
Waleed Latif
a37c6bc812 fix(build): clear docker build cache to use correct Next.js version (#1075)
* fix: clear Docker build cache to use correct Next.js version

- Changed GitHub Actions cache scope from build-v2 to build-v3
- This should force a fresh build without cached Next.js 15.5.0 layers
- Reverted to ^15.3.2 version format that worked on main branch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* run install

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-21 01:38:47 -07:00
Waleed Latif
cd1bd95952 fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:24:06 -07:00
Waleed Latif
4c9fdbe7fb fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:23:10 -07:00
Waleed Latif
2c47cf4161 v0.3.34: azure-openai options, billing fixes, mistral OCR via Azure, start block input format changes 2025-08-20 21:05:48 -07:00
Waleed Latif
db1cf8a6db fix(placeholder): fix starter block placeholder (#1071) 2025-08-20 21:01:37 -07:00
Vikhyath Mondreti
c6912095f7 fix placeholder text 2025-08-20 20:38:15 -07:00
Waleed Latif
154d9eef6a fix(gpt-5): fix chat-completions api (#1070) 2025-08-20 20:36:12 -07:00
Emir Karabeg
c2ded1f3e1 fix(theme-provider): preventing flash on page load (#1067)
* fix(theme-provider): preventing flash on page load

* consolidated themes to use NextJS theme logic

* improvement: optimized latency
2025-08-20 20:20:23 -07:00
Waleed Latif
ff43528d35 fix(gpt-5): fixed verbosity and reasoning params (#1069)
* fix(gpt-5): fixed verbosity and reasoning parsm

* fixed dropdown

* default values for verbosity and reasoning effort

* cleanup

* use default value in dropdown
2025-08-20 20:18:02 -07:00
Vikhyath Mondreti
692ba69864 fix type 2025-08-20 20:00:41 -07:00
Adam Gough
cb7ce8659b fix(msverify): changed consent for microsoft (#1057)
* changed consent

* changed excel error message and default sheets

* changed variable res for excel

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-20 19:54:51 -07:00
Vikhyath Mondreti
5caef3a37d fix(input-format): first time execution bug (#1068) 2025-08-20 19:52:04 -07:00
Waleed Latif
a6888da124 fix(semantics): fix incorrect imports (#1066)
* fix(semantics): fix incorrect import

* fixed all incorrecr imports
2025-08-20 19:02:52 -07:00
Vikhyath Mondreti
07b0597f4f improvement(trigger): upgrade import path for trigger (#1065) 2025-08-20 18:41:13 -07:00
Vikhyath Mondreti
71e2994f9d improvement(trigger): upgrade trigger (#1063) 2025-08-20 18:33:01 -07:00
Vikhyath Mondreti
9973b2c165 Merge branch 'staging' of github.com:simstudioai/sim into staging 2025-08-20 18:26:08 -07:00
Vikhyath Mondreti
d9e5777538 use personal access token 2025-08-20 18:24:17 -07:00
Waleed Latif
dd74267313 feat(nextjs): upgrade nextjs to 15.5 (#1062) 2025-08-20 18:22:35 -07:00
Vikhyath Mondreti
1db72dc823 pin version 2025-08-20 18:13:15 -07:00
Vikhyath Mondreti
da707fa491 improvement(gh-action): add gh action to deploy to correct environment for trigger.dev (#1060)
* improvement(gh-action): add gh action to deploy to correct environment for trigger.dev

* add dep installation

* change away from pull request target
2025-08-20 18:10:43 -07:00
Vikhyath Mondreti
9ffaf305bd feat(input-format): add value field to test input formats (#1059)
* feat(input-format): add value field to test input formats

* fix lint

* fix typing issue

* change to dropdown for boolean
2025-08-20 18:03:47 -07:00
Waleed Latif
26e6286fda fix(billing): fix team plan upgrade (#1053) 2025-08-20 17:05:35 -07:00
Waleed Latif
c795fc83aa feat(azure-openai): allow usage of azure-openai for knowledgebase uploads and wand generation (#1056)
* feat(azure-openai): allow usage of azure-openai for knowledgebase uploads

* feat(azure-openai): added azure-openai for kb and wand

* added embeddings utils, added the ability to use mistral through Azure

* fix(oauth): gdrive picker race condition, token route cleanup

* fix test

* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS (#1054)

* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS

* fix batch invitation email template

* cleanup

* improvement(emails): add help template instead of doing it inline

* remove fallback version

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-08-20 17:04:52 -07:00
Waleed Latif
cea42f5135 improvement(gpt-5): added reasoning level and verbosity to gpt-5 models (#1058) 2025-08-20 17:04:39 -07:00
Waleed Latif
6fd6f921dc feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS (#1054)
* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS

* fix batch invitation email template

* cleanup

* improvement(emails): add help template instead of doing it inline
2025-08-20 16:02:49 -07:00
Vikhyath Mondreti
7530fb9a4e Merge pull request #1055 from simstudioai/fix/picker-race-cond
fix(oauth): gdrive picker race condition, token route cleanup
2025-08-20 15:03:57 -07:00
Vikhyath Mondreti
9a5b035822 fix test 2025-08-20 13:55:54 -07:00
Vikhyath Mondreti
0c0b6bf967 fix(oauth): gdrive picker race condition, token route cleanup 2025-08-20 12:33:46 -07:00
137 changed files with 11846 additions and 2971 deletions

View File

@@ -85,8 +85,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-v2
cache-to: type=gha,mode=max,scope=build-v2
cache-from: type=gha,scope=build-v3
cache-to: type=gha,mode=max,scope=build-v3
provenance: false
sbom: false

44
.github/workflows/trigger-deploy.yml vendored Normal file
View 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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,9 @@ import { auth } from '@/lib/auth'
export async function POST() {
try {
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: await headers(),
headers: hdrs,
})
if (!response) {
@@ -14,7 +15,6 @@ export async function POST() {
return NextResponse.json({ token: response.token })
} catch (error) {
console.error('Error generating one-time token:', error)
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

@@ -1,12 +1,13 @@
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 { getFromEmailAddress } from '@/lib/email/utils'
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 +29,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 +43,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 +92,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: getFromEmailAddress(),
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: getFromEmailAddress(),
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 }
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { db } from '@/db'
import { templates } from '@/db/schema'
import { templates, workflow } from '@/db/schema'
const logger = createLogger('TemplateByIdAPI')
@@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const updateTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().min(1).max(500),
author: z.string().min(1).max(100),
category: z.string().min(1),
icon: z.string().min(1),
color: z.string().regex(/^#[0-9A-F]{6}$/i),
state: z.any().optional(), // Workflow state
})
// PUT /api/templates/[id] - Update a template
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validationResult = updateTemplateSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error)
return NextResponse.json(
{ error: 'Invalid template data', details: validationResult.error.errors },
{ status: 400 }
)
}
const { name, description, author, category, icon, color, state } = validationResult.data
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for update: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Permission: template owner OR admin of the workflow's workspace (if any)
let canUpdate = existingTemplate[0].userId === session.user.id
if (!canUpdate && existingTemplate[0].workflowId) {
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canUpdate = true
}
}
if (!canUpdate) {
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Update the template
const updatedTemplate = await db
.update(templates)
.set({
name,
description,
author,
category,
icon,
color,
...(state && { state }),
updatedAt: new Date(),
})
.where(eq(templates.id, id))
.returning()
logger.info(`[${requestId}] Successfully updated template: ${id}`)
return NextResponse.json({
data: updatedTemplate[0],
message: 'Template updated successfully',
})
} catch (error: any) {
logger.error(`[${requestId}] Error updating template: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// DELETE /api/templates/[id] - Delete a template
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch template
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
const template = existing[0]
// Permission: owner or admin of the workflow's workspace (if any)
let canDelete = template.userId === session.user.id
if (!canDelete && template.workflowId) {
// Look up workflow to get workspaceId
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, template.workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canDelete = true
}
}
if (!canDelete) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await db.delete(templates).where(eq(templates.id, id))
logger.info(`[${requestId}] Deleted template: ${id}`)
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({
limit: z.coerce.number().optional().default(50),
offset: z.coerce.number().optional().default(0),
search: z.string().optional(),
workflowId: z.string().optional(),
})
// GET /api/templates - Retrieve templates
@@ -111,6 +112,11 @@ export async function GET(request: NextRequest) {
)
}
// Apply workflow filter if provided (for getting template by workflow)
if (params.workflowId) {
conditions.push(eq(templates.workflowId, params.workflowId))
}
// Combine conditions
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
@@ -242,6 +244,167 @@ export async function DELETE(
const foundWebhook = webhookData.webhook
// If it's an Airtable webhook, delete it from Airtable first
if (foundWebhook.provider === 'airtable') {
try {
const { baseId, externalId } = (foundWebhook.providerConfig || {}) as {
baseId?: string
externalId?: string
}
if (!baseId) {
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion.`, {
webhookId: id,
})
return NextResponse.json(
{ error: 'Missing baseId for Airtable webhook deletion' },
{ status: 400 }
)
}
// Get access token for the workflow owner
const userIdForToken = webhookData.workflow.userId
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
{ webhookId: id }
)
return NextResponse.json(
{ error: 'Airtable access token not found for webhook deletion' },
{ status: 401 }
)
}
// Resolve externalId if missing by listing webhooks and matching our notificationUrl
let resolvedExternalId: string | undefined = externalId
if (!resolvedExternalId) {
try {
const requestOrigin = new URL(request.url).origin
const effectiveOrigin = requestOrigin.includes('localhost')
? env.NEXT_PUBLIC_APP_URL || requestOrigin
: requestOrigin
const expectedNotificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${foundWebhook.path}`
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const listBody = await listResp.json().catch(() => null)
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
const match = listBody.webhooks.find((w: any) => {
const url: string | undefined = w?.notificationUrl
if (!url) return false
// Prefer exact match; fallback to suffix match to handle origin/host remaps
return (
url === expectedNotificationUrl ||
url.endsWith(`/api/webhooks/trigger/${foundWebhook.path}`)
)
})
if (match?.id) {
resolvedExternalId = match.id as string
// Persist resolved externalId for future operations
try {
await db
.update(webhook)
.set({
providerConfig: {
...(foundWebhook.providerConfig || {}),
externalId: resolvedExternalId,
},
updatedAt: new Date(),
})
.where(eq(webhook.id, id))
} catch {
// non-fatal persistence error
}
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
baseId,
externalId: resolvedExternalId,
})
} else {
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
baseId,
expectedNotificationUrl,
})
}
} else {
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
baseId,
status: listResp.status,
body: listBody,
})
}
} catch (e: any) {
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
error: e?.message,
})
}
}
// If still not resolvable, skip remote deletion but proceed with local delete
if (!resolvedExternalId) {
logger.info(
`[${requestId}] Airtable externalId not found; skipping remote deletion and proceeding to remove local record`,
{ baseId }
)
}
if (resolvedExternalId) {
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
const airtableResponse = await fetch(airtableDeleteUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
// Attempt to parse error body for better diagnostics
if (!airtableResponse.ok) {
let responseBody: any = null
try {
responseBody = await airtableResponse.json()
} catch {
// ignore parse errors
}
logger.error(
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
{ baseId, externalId: resolvedExternalId, response: responseBody }
)
return NextResponse.json(
{
error: 'Failed to delete webhook from Airtable',
details:
(responseBody && (responseBody.error?.message || responseBody.error)) ||
`Status ${airtableResponse.status}`,
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
baseId,
externalId: resolvedExternalId,
})
}
} catch (error: any) {
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
webhookId: id,
error: error.message,
stack: error.stack,
})
return NextResponse.json(
{ error: 'Failed to delete webhook from Airtable', details: error.message },
{ status: 500 }
)
}
}
// If it's a Telegram webhook, delete it from Telegram first
if (foundWebhook.provider === 'telegram') {
try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
const logger = createLogger('WorkflowByIdAPI')
@@ -218,6 +218,48 @@ export async function DELETE(
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
if (checkTemplates) {
// Return template information for frontend to handle
const publishedTemplates = await db
.select()
.from(templates)
.where(eq(templates.workflowId, workflowId))
return NextResponse.json({
hasPublishedTemplates: publishedTemplates.length > 0,
count: publishedTemplates.length,
publishedTemplates: publishedTemplates.map((t) => ({
id: t.id,
name: t.name,
views: t.views,
stars: t.stars,
})),
})
}
// Handle template deletion based on user choice
if (deleteTemplatesParam !== null) {
const deleteTemplates = deleteTemplatesParam === 'delete'
if (deleteTemplates) {
// Delete all templates associated with this workflow
await db.delete(templates).where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
} else {
// Orphan the templates (set workflowId to null)
await db
.update(templates)
.set({ workflowId: null })
.where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
}
}
await db.delete(workflow).where(eq(workflow.id, workflowId))
const elapsed = Date.now() - startTime

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
@@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI')
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { knowledgeBase, permissions, workspace } from '@/db/schema'
import { knowledgeBase, permissions, templates, workspace } from '@/db/schema'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
@@ -19,6 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const workspaceId = id
const url = new URL(request.url)
const checkTemplates = url.searchParams.get('check-templates') === 'true'
// Check if user has any access to this workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -26,6 +28,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
}
// If checking for published templates before deletion
if (checkTemplates) {
try {
// Get all workflows in this workspace
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
if (workspaceWorkflows.length === 0) {
return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] })
}
const workflowIds = workspaceWorkflows.map((w) => w.id)
// Check for published templates that reference these workflows
const publishedTemplates = await db
.select({
id: templates.id,
name: templates.name,
workflowId: templates.workflowId,
})
.from(templates)
.where(inArray(templates.workflowId, workflowIds))
return NextResponse.json({
hasPublishedTemplates: publishedTemplates.length > 0,
publishedTemplates,
count: publishedTemplates.length,
})
} catch (error) {
logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
}
}
// Get workspace details
const workspaceDetails = await db
.select()
@@ -108,6 +146,8 @@ export async function DELETE(
}
const workspaceId = id
const body = await request.json().catch(() => ({}))
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates
// Check if user has admin permissions to delete workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -116,10 +156,39 @@ export async function DELETE(
}
try {
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
logger.info(
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
)
// Delete workspace and all related data in a transaction
await db.transaction(async (tx) => {
// Get all workflows in this workspace before deletion
const workspaceWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
if (workspaceWorkflows.length > 0) {
const workflowIds = workspaceWorkflows.map((w) => w.id)
// Handle templates based on user choice
if (deleteTemplates) {
// Delete published templates that reference these workflows
await tx.delete(templates).where(inArray(templates.workflowId, workflowIds))
logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
} else {
// Set workflowId to null for templates to create "orphaned" templates
// This allows templates to remain in marketplace but without source workflows
await tx
.update(templates)
.set({ workflowId: null })
.where(inArray(templates.workflowId, workflowIds))
logger.info(
`Updated templates to orphaned status for workflows in workspace ${workspaceId}`
)
}
}
// Delete all workflows in the workspace - database cascade will handle all workflow-related data
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
// workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,

View File

@@ -91,6 +91,7 @@ describe('Workspace Invitations API Route', () => {
env: {
RESEND_API_KEY: 'test-resend-key',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
},
}))

View File

@@ -2,12 +2,12 @@ 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 { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
import {
permissions,
@@ -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,23 @@ 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 = getFromEmailAddress()
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',
})
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

View File

@@ -3,6 +3,7 @@ import { SpeedInsights } from '@vercel/speed-insights/next'
import type { Metadata, Viewport } from 'next'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { generateThemeCSS } from '@/lib/branding/inject-theme'
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
import { env } from '@/lib/env'
import { isHosted } from '@/lib/environment'
@@ -10,6 +11,8 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import '@/app/globals.css'
import { SessionProvider } from '@/lib/session-context'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
const logger = createLogger('RootLayout')
@@ -45,11 +48,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
@@ -57,6 +63,7 @@ export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
const structuredData = generateStructuredData()
const themeCSS = generateThemeCSS()
return (
<html lang='en' suppressHydrationWarning>
@@ -69,9 +76,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}}
/>
{/* Theme CSS Override */}
{themeCSS && (
<style
id='theme-override'
dangerouslySetInnerHTML={{
__html: themeCSS,
}}
/>
)}
{/* 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 +123,20 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)}
</head>
<body suppressHydrationWarning>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
<ThemeProvider>
<SessionProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
</SessionProvider>
</ThemeProvider>
</body>
</html>
)

View File

@@ -11,8 +11,8 @@ export default function manifest(): MetadataRoute.Manifest {
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
start_url: '/',
display: 'standalone',
background_color: '#701FFC', // Default Sim brand primary color
theme_color: '#701FFC', // Default Sim brand primary color
background_color: brand.theme?.backgroundColor || '#701FFC',
theme_color: brand.theme?.primaryColor || '#701FFC',
icons: [
{
src: '/favicon/android-chrome-192x192.png',

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value']
// Template data structure
export interface Template {
id: string
workflowId: string
workflowId: string | null
userId: string
name: string
description: string | null

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Award,
@@ -18,6 +18,7 @@ import {
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
@@ -48,6 +49,16 @@ import {
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -68,6 +79,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
@@ -100,7 +112,6 @@ interface TemplateModalProps {
workflowId: string
}
// Enhanced icon selection with category-relevant icons
const icons = [
// Content & Documentation
{ value: 'FileText', label: 'File Text', component: FileText },
@@ -165,6 +176,10 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
@@ -178,6 +193,63 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
},
})
// Watch form state to determine if all required fields are valid
const formValues = form.watch()
const isFormValid =
form.formState.isValid &&
formValues.name?.trim() &&
formValues.description?.trim() &&
formValues.author?.trim() &&
formValues.category
// Check for existing template when modal opens
useEffect(() => {
if (open && workflowId) {
checkExistingTemplate()
}
}, [open, workflowId])
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
// Pre-fill form with existing template data
if (template) {
form.reset({
name: template.name,
description: template.description,
author: template.author,
category: template.category,
icon: template.icon,
color: template.color,
})
} else {
// No existing template found
setExistingTemplate(null)
// Reset form to defaults
form.reset({
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
category: '',
icon: 'FileText',
color: '#3972F6',
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
}
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
@@ -201,21 +273,36 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
state: templateState,
}
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
let response
if (existingTemplate) {
// Update existing template
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
} else {
// Create new template
response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create template')
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info('Template created successfully:', result)
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Reset form and close modal
form.reset()
@@ -241,7 +328,35 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>
{isLoadingTemplate
? 'Loading...'
: existingTemplate
? 'Update Template'
: 'Publish Template'}
</DialogTitle>
{existingTemplate && (
<div className='flex items-center gap-2'>
{existingTemplate.stars > 0 && (
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
{existingTemplate.stars}
</span>
</div>
)}
{existingTemplate.views > 0 && (
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
<Eye className='h-3 w-3 text-blue-500' />
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
{existingTemplate.views}
</span>
</div>
)}
</div>
)}
</div>
<Button
variant='ghost'
size='icon'
@@ -259,65 +374,189 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto px-6 py-4'>
<div className='space-y-6'>
<div className='flex gap-3'>
<div className='flex-1 overflow-y-auto px-6 py-6'>
{isLoadingTemplate ? (
<div className='space-y-6'>
{/* Icon and Color row */}
<div className='flex gap-3'>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
</div>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Color picker */}
</div>
</div>
{/* Name field */}
<div>
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Author and Category row */}
<div className='grid grid-cols-2 gap-4'>
<div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
<div>
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
</div>
{/* Description field */}
<div>
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
<Skeleton className='h-20 w-full' /> {/* Textarea */}
</div>
</div>
) : (
<div className='space-y-6'>
<div className='flex gap-3'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Icon
</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
field.value === icon.value &&
'bg-primary text-primary-foreground'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Color
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='icon'
name='name'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Icon</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
field.value === icon.value &&
'bg-primary text-primary-foreground'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
<FormControl>
<Input placeholder='Enter template name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author
</FormLabel>
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Category
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='color'
name='description'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Color</FormLabel>
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Description
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20'
<Textarea
placeholder='Describe what this template does...'
className='resize-none'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
@@ -325,91 +564,28 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
)}
/>
</div>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Enter template name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel>Author</FormLabel>
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what this template does...'
className='resize-none'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-end'>
<div className='flex items-center'>
{existingTemplate && (
<Button
type='button'
variant='destructive'
onClick={() => setShowDeleteDialog(true)}
disabled={isSubmitting || isLoadingTemplate}
className='h-10 rounded-md px-4 py-2'
>
Delete
</Button>
)}
<Button
type='submit'
disabled={isSubmitting}
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
className={cn(
'font-medium',
'ml-auto font-medium',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
@@ -420,16 +596,59 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Publishing...
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish'
'Publish Template'
)}
</Button>
</div>
</div>
</form>
</Form>
{existingTemplate && (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this template will remove it from the gallery. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isDeleting}
onClick={async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.error || 'Failed to delete template')
}
setShowDeleteDialog(false)
onOpenChange(false)
} catch (err) {
logger.error('Failed to delete template', err)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</DialogContent>
</Dialog>
)

View File

@@ -18,7 +18,6 @@ import {
import { useParams, useRouter } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@@ -113,6 +112,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
// Delete workflow state - grouped for better organization
const [deleteState, setDeleteState] = useState({
showDialog: false,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [] as any[],
showTemplateChoice: false,
})
// Deployed state management
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
@@ -337,35 +345,170 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
/**
* Handle deleting the current workflow
* Reset delete state
*/
const handleDeleteWorkflow = () => {
const resetDeleteState = useCallback(() => {
setDeleteState({
showDialog: false,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [],
showTemplateChoice: false,
})
}, [])
/**
* Navigate to next workflow after deletion
*/
const navigateAfterDeletion = useCallback(
(currentWorkflowId: string) => {
const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
// Find next workflow: try next, then previous
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
}
}
// Navigate to next workflow or workspace home
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}`)
}
},
[workspaceId, router]
)
/**
* Check if workflow has published templates
*/
const checkPublishedTemplates = useCallback(async (workflowId: string) => {
const checkResponse = await fetch(`/api/workflows/${workflowId}?check-templates=true`, {
method: 'DELETE',
})
if (!checkResponse.ok) {
throw new Error(`Failed to check templates: ${checkResponse.statusText}`)
}
return await checkResponse.json()
}, [])
/**
* Delete workflow with optional template handling
*/
const deleteWorkflowWithTemplates = useCallback(
async (workflowId: string, templateAction?: 'keep' | 'delete') => {
const endpoint = templateAction
? `/api/workflows/${workflowId}?deleteTemplates=${templateAction}`
: null
if (endpoint) {
// Use custom endpoint for template handling
const response = await fetch(endpoint, { method: 'DELETE' })
if (!response.ok) {
throw new Error(`Failed to delete workflow: ${response.statusText}`)
}
// Manual registry cleanup since we used custom API
useWorkflowRegistry.setState((state) => {
const newWorkflows = { ...state.workflows }
delete newWorkflows[workflowId]
return {
...state,
workflows: newWorkflows,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
}
})
} else {
// Use registry's built-in deletion (handles database + state)
await useWorkflowRegistry.getState().removeWorkflow(workflowId)
}
},
[]
)
/**
* Handle deleting the current workflow - called after user confirms
*/
const handleDeleteWorkflow = useCallback(async () => {
const currentWorkflowId = params.workflowId as string
if (!currentWorkflowId || !userPermissions.canEdit) return
const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
// Find next workflow: try next, then previous
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
try {
// Check if workflow has published templates
const checkData = await checkPublishedTemplates(currentWorkflowId)
if (checkData.hasPublishedTemplates) {
setDeleteState((prev) => ({
...prev,
hasPublishedTemplates: true,
publishedTemplates: checkData.publishedTemplates || [],
showTemplateChoice: true,
isDeleting: false, // Stop showing "Deleting..." and show template choice
}))
return
}
}
// Navigate to next workflow or workspace home
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}`)
// No templates, proceed with standard deletion
navigateAfterDeletion(currentWorkflowId)
await deleteWorkflowWithTemplates(currentWorkflowId)
resetDeleteState()
} catch (error) {
logger.error('Error deleting workflow:', error)
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
}
}, [
params.workflowId,
userPermissions.canEdit,
checkPublishedTemplates,
navigateAfterDeletion,
deleteWorkflowWithTemplates,
resetDeleteState,
])
// Remove the workflow from the registry using the URL parameter
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
}
/**
* Handle template action selection
*/
const handleTemplateAction = useCallback(
async (action: 'keep' | 'delete') => {
const currentWorkflowId = params.workflowId as string
if (!currentWorkflowId || !userPermissions.canEdit) return
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
try {
logger.info(`Deleting workflow ${currentWorkflowId} with template action: ${action}`)
navigateAfterDeletion(currentWorkflowId)
await deleteWorkflowWithTemplates(currentWorkflowId, action)
logger.info(
`Successfully deleted workflow ${currentWorkflowId} with template action: ${action}`
)
resetDeleteState()
} catch (error) {
logger.error('Error deleting workflow:', error)
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
}
},
[
params.workflowId,
userPermissions.canEdit,
navigateAfterDeletion,
deleteWorkflowWithTemplates,
resetDeleteState,
]
)
// Helper function to open subscription settings
const openSubscriptionSettings = () => {
@@ -422,7 +565,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
return (
<AlertDialog>
<AlertDialog
open={deleteState.showDialog}
onOpenChange={(open) => {
if (open) {
// Reset all state when opening dialog to ensure clean start
setDeleteState({
showDialog: true,
isDeleting: false,
hasPublishedTemplates: false,
publishedTemplates: [],
showTemplateChoice: false,
})
} else {
resetDeleteState()
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
@@ -444,21 +603,71 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
<AlertDialogTitle>
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
</AlertDialogTitle>
{deleteState.showTemplateChoice ? (
<div className='space-y-3'>
<AlertDialogDescription>
This workflow has {deleteState.publishedTemplates.length} published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
</AlertDialogDescription>
{deleteState.publishedTemplates.length > 0 && (
<ul className='list-disc space-y-1 pl-6'>
{deleteState.publishedTemplates.map((template) => (
<li key={template.id}>{template.name}</li>
))}
</ul>
)}
<AlertDialogDescription>
What would you like to do with the published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
</AlertDialogDescription>
</div>
) : (
<AlertDialogDescription>
Deleting this workflow will permanently remove all associated blocks, executions,
and configuration.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteWorkflow}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
Delete
</AlertDialogAction>
{deleteState.showTemplateChoice ? (
<div className='flex w-full gap-2'>
<Button
variant='outline'
onClick={() => handleTemplateAction('keep')}
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px]'
>
Keep templates
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
</Button>
</div>
) : (
<>
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
<Button
onClick={(e) => {
e.preventDefault()
handleDeleteWorkflow()
}}
disabled={deleteState.isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{deleteState.isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -1002,10 +1211,10 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderToggleButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()}
{renderDuplicateButton()}
{renderDeleteButton()}
{!isDebugging && renderDebugModeToggle()}
{isExpanded && renderPublishButton()}
{renderDeleteButton()}
{renderDuplicateButton()}
{!isDebugging && renderDebugModeToggle()}
{renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()}

View File

@@ -1,73 +0,0 @@
'use client'
import * as React from 'react'
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
interface DateInputProps {
blockId: string
subBlockId: string
placeholder?: string
isPreview?: boolean
previewValue?: string | null
disabled?: boolean
}
export function DateInput({
blockId,
subBlockId,
placeholder,
isPreview = false,
previewValue,
disabled = false,
}: DateInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const date = value ? new Date(value) : undefined
const isPastDate = React.useMemo(() => {
if (!date) return false
const today = new Date()
today.setHours(0, 0, 0, 0)
return date < today
}, [date])
const handleDateSelect = (selectedDate: Date | undefined) => {
if (isPreview || disabled) return
if (selectedDate) {
const today = new Date()
today.setHours(0, 0, 0, 0)
}
setStoreValue(selectedDate?.toISOString() || '')
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isPreview || disabled}
className={cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground',
isPastDate && 'border-red-500'
)}
>
<CalendarIcon className='mr-1 h-4 w-4' />
{date ? format(date, 'MMM d, yy') : <span>{placeholder || 'Pick a date'}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar mode='single' selected={date} onSelect={handleDateSelect} initialFocus />
</PopoverContent>
</Popover>
)
}

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,8 @@ export function MicrosoftFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops
const lastMetaAttemptRef = useRef<string>('')
// Handle Microsoft Planner task selection
const [plannerTasks, setPlannerTasks] = useState<PlannerTask[]>([])
@@ -496,11 +498,15 @@ export function MicrosoftFileSelector({
setSelectedFileId('')
onChange('')
}
// Reset memo when credential is cleared
lastMetaAttemptRef.current = ''
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
// Reset memo when switching credentials
lastMetaAttemptRef.current = ''
}
}, [selectedCredentialId, selectedFile, onChange])
@@ -514,10 +520,17 @@ export function MicrosoftFileSelector({
(!selectedFile || selectedFile.id !== value) &&
!isLoadingSelectedFile
) {
// Avoid tight retry loops by memoizing the last attempt tuple
const attemptKey = `${selectedCredentialId}::${value}`
if (lastMetaAttemptRef.current === attemptKey) {
return
}
lastMetaAttemptRef.current = attemptKey
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
fetchFileById(value)
void fetchFileById(value)
}
}
}, [

View File

@@ -3,6 +3,7 @@
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
DiscordChannelSelector,
@@ -73,8 +74,12 @@ export function FileSelectorInput({
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
const foreignCheckProvider = subBlock.serviceId
? getProviderIdFromServiceId(subBlock.serviceId)
: (subBlock.provider as string) || ''
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || '',
foreignCheckProvider,
(connectedCredential as string) || ''
)
@@ -224,12 +229,6 @@ export function FileSelectorInput({
}
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'
@@ -353,7 +352,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
@@ -389,7 +388,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={disabled || !credential || !planId}
disabled={finalDisabled}
showPreview={true}
planId={planId}
workflowId={activeWorkflowId || ''}
@@ -447,7 +446,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
credential={credential}
selectionType={selectionType}
@@ -490,7 +489,7 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
credentialId={credential}
itemType={itemType}
@@ -531,7 +530,7 @@ export function FileSelectorInput({
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled || !credential}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}

View File

@@ -4,7 +4,6 @@ export { Code } from './code'
export { ComboBox } from './combobox'
export { ConditionInput } from './condition-input'
export { CredentialSelector } from './credential-selector/credential-selector'
export { DateInput } from './date-input'
export { DocumentSelector } from './document-selector/document-selector'
export { Dropdown } from './dropdown'
export { EvalInput } from './eval-input'

View File

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

View File

@@ -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 {
@@ -20,7 +20,6 @@ import {
CheckboxList,
Code,
ComboBox,
DateInput,
FileSelectorInput,
FileUpload,
LongInput,
@@ -49,6 +48,8 @@ import {
type ToolParameterConfig,
} from '@/tools/params'
const logger = createLogger('ToolInput')
interface ToolInputProps {
blockId: string
subBlockId: string
@@ -170,33 +171,6 @@ function TableSyncWrapper({
)
}
function DateInputSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<DateInput
blockId={blockId}
subBlockId={paramId}
placeholder={uiComponent.placeholder}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
function TimeInputSyncWrapper({
blockId,
paramId,
@@ -1157,18 +1131,6 @@ export function ToolInput({
/>
)
case 'date-input':
return (
<DateInputSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
/>
)
case 'time-input':
return (
<TimeInputSyncWrapper

View File

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

View File

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

View File

@@ -65,7 +65,8 @@ export function useSubBlockValue<T = any>(
const storeValue = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
if (!activeWorkflowId) return undefined
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]

View File

@@ -11,7 +11,6 @@ import {
ComboBox,
ConditionInput,
CredentialSelector,
DateInput,
DocumentSelector,
Dropdown,
EvalInput,
@@ -126,9 +125,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 +141,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}
@@ -252,17 +255,6 @@ export function SubBlock({
disabled={isDisabled}
/>
)
case 'date-input':
return (
<DateInput
blockId={blockId}
subBlockId={config.id}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
/>
)
case 'time-input':
return (
<TimeInput
@@ -435,6 +427,7 @@ export function SubBlock({
disabled={isDisabled}
isConnecting={isConnecting}
config={config}
showValue={true}
/>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
import { NavigationItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/navigation-item/navigation-item'
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
interface FloatingNavigationProps {
workspaceId: string
pathname: string
onShowSettings: () => void
onShowHelp: () => void
bottom: number
}
export const FloatingNavigation = ({
workspaceId,
pathname,
onShowSettings,
onShowHelp,
bottom,
}: FloatingNavigationProps) => {
// Navigation items with their respective actions
const navigationItems = [
{
id: 'settings',
icon: Settings,
onClick: onShowSettings,
tooltip: 'Settings',
},
{
id: 'help',
icon: HelpCircle,
onClick: onShowHelp,
tooltip: 'Help',
},
{
id: 'logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{
id: 'knowledge',
icon: LibraryBig,
href: `/workspace/${workspaceId}/knowledge`,
tooltip: 'Knowledge',
active: pathname === `/workspace/${workspaceId}/knowledge`,
},
{
id: 'templates',
icon: Shapes,
href: `/workspace/${workspaceId}/templates`,
tooltip: 'Templates',
active: pathname === `/workspace/${workspaceId}/templates`,
},
]
return (
<div className='pointer-events-auto fixed left-4 z-50 w-56' style={{ bottom: `${bottom}px` }}>
<div className='flex items-center gap-1'>
{navigationItems.map((item) => (
<NavigationItem key={item.id} item={item} />
))}
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
export { CreateMenu } from './create-menu/create-menu'
export { FloatingNavigation } from './floating-navigation/floating-navigation'
export { FolderTree } from './folder-tree/folder-tree'
export { HelpModal } from './help-modal/help-modal'
export { KeyboardShortcut } from './keyboard-shortcut/keyboard-shortcut'
export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags'
export { KnowledgeTags } from './knowledge-tags/knowledge-tags'
export { LogsFilters } from './logs-filters/logs-filters'
export { NavigationItem } from './navigation-item/navigation-item'
export { SettingsModal } from './settings-modal/settings-modal'
export { SubscriptionModal } from './subscription-modal/subscription-modal'
export { Toolbar } from './toolbar/toolbar'

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
interface KeyboardShortcutProps {
shortcut: string
className?: string
}
export const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
const parts = shortcut.split('+')
// Helper function to determine if a part is a symbol that should be larger
const isSymbol = (part: string) => {
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
}
return (
<kbd
className={cn(
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
className
)}
>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
{parts.map((part, index) => (
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
{part}
</span>
))}
</span>
</kbd>
)
}

View File

@@ -0,0 +1,64 @@
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { cn } from '@/lib/utils'
interface NavigationItemProps {
item: {
id: string
icon: React.ElementType
onClick?: () => void
href?: string
tooltip: string
shortcut?: string
active?: boolean
disabled?: boolean
}
}
export const NavigationItem = ({ item }: NavigationItemProps) => {
// Settings and help buttons get gray hover, others get purple hover
const isGrayHover = item.id === 'settings' || item.id === 'help'
const content = item.disabled ? (
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<item.icon className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover &&
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
)}
>
<item.icon className='h-4 w-4' />
</Button>
)
if (item.href && !item.disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a href={item.href} className='inline-block'>
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}

View File

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

View File

@@ -44,7 +44,7 @@ interface WorkspaceSelectorProps {
onWorkspaceUpdate: () => Promise<void>
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
onCreateWorkspace: () => Promise<void>
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
onDeleteWorkspace: (workspace: Workspace, templateAction?: 'keep' | 'delete') => Promise<void>
onLeaveWorkspace: (workspace: Workspace) => Promise<void>
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
isDeleting: boolean
@@ -76,6 +76,14 @@ export function WorkspaceSelector({
const [isRenaming, setIsRenaming] = useState(false)
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
const [leaveConfirmationName, setLeaveConfirmationName] = useState('')
const [isCheckingTemplates, setIsCheckingTemplates] = useState(false)
const [showTemplateChoice, setShowTemplateChoice] = useState(false)
const [templatesInfo, setTemplatesInfo] = useState<{
count: number
templates: Array<{ id: string; name: string }>
} | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [workspaceToDelete, setWorkspaceToDelete] = useState<Workspace | null>(null)
// Refs
const scrollAreaRef = useRef<HTMLDivElement>(null)
@@ -206,15 +214,82 @@ export function WorkspaceSelector({
)
/**
* Confirm delete workspace
* Reset delete dialog state
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
await onDeleteWorkspace(workspaceToDelete)
const resetDeleteState = useCallback(() => {
setDeleteConfirmationName('')
setShowTemplateChoice(false)
setTemplatesInfo(null)
setIsCheckingTemplates(false)
setWorkspaceToDelete(null)
}, [])
/**
* Handle dialog close
*/
const handleDialogClose = useCallback(
(open: boolean) => {
if (!open) {
resetDeleteState()
}
setIsDeleteDialogOpen(open)
},
[onDeleteWorkspace]
[resetDeleteState]
)
/**
* Handle template choice action
*/
const handleTemplateAction = useCallback(
async (action: 'keep' | 'delete') => {
if (!workspaceToDelete) return
setShowTemplateChoice(false)
setTemplatesInfo(null)
setDeleteConfirmationName('')
await onDeleteWorkspace(workspaceToDelete, action)
setWorkspaceToDelete(null)
setIsDeleteDialogOpen(false)
},
[workspaceToDelete, onDeleteWorkspace]
)
/**
* Check for templates and handle deletion
*/
const handleDeleteClick = useCallback(async () => {
if (!workspaceToDelete) return
setIsCheckingTemplates(true)
try {
const checkResponse = await fetch(
`/api/workspaces/${workspaceToDelete.id}?check-templates=true`
)
if (checkResponse.ok) {
const templateCheck = await checkResponse.json()
if (templateCheck.hasPublishedTemplates && templateCheck.count > 0) {
// Templates exist - show template choice
setTemplatesInfo({
count: templateCheck.count,
templates: templateCheck.publishedTemplates,
})
setShowTemplateChoice(true)
setIsCheckingTemplates(false)
return
}
}
} catch (error) {
logger.error('Error checking templates:', error)
}
// No templates or error - proceed with deletion
setIsCheckingTemplates(false)
setDeleteConfirmationName('')
await onDeleteWorkspace(workspaceToDelete)
setWorkspaceToDelete(null)
setIsDeleteDialogOpen(false)
}, [workspaceToDelete, onDeleteWorkspace])
/**
* Confirm leave workspace
*/
@@ -352,7 +427,7 @@ export function WorkspaceSelector({
<Input
value={leaveConfirmationName}
onChange={(e) => setLeaveConfirmationName(e.target.value)}
placeholder='Placeholder'
placeholder={workspace.name}
className='h-9'
/>
</div>
@@ -381,66 +456,21 @@ export function WorkspaceSelector({
{/* Delete Workspace - for admin users */}
{workspace.permissions === 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => e.stopPropagation()}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<Trash2 className='!h-3.5 !w-3.5' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this workspace will permanently remove all associated workflows,
logs, and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name{' '}
<span className='font-semibold'>{workspace.name}</span> to confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Placeholder'
className='h-9 rounded-[8px]'
/>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setDeleteConfirmationName('')}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmDeleteWorkspace(workspace)
setDeleteConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting || deleteConfirmationName !== workspace.name}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
setWorkspaceToDelete(workspace)
setIsDeleteDialogOpen(true)
}}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<Trash2 className='!h-3.5 !w-3.5' />
</Button>
)}
</div>
</div>
@@ -496,6 +526,106 @@ export function WorkspaceSelector({
</div>
</div>
{/* Centralized Delete Workspace Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={handleDialogClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{showTemplateChoice
? 'Delete workspace with published templates?'
: 'Delete workspace?'}
</AlertDialogTitle>
<AlertDialogDescription>
{showTemplateChoice ? (
<>
This workspace contains {templatesInfo?.count} published template
{templatesInfo?.count === 1 ? '' : 's'}:
<br />
<br />
{templatesInfo?.templates.map((template) => (
<span key={template.id} className='block'>
{template.name}
</span>
))}
<br />
What would you like to do with the published templates?
</>
) : (
<>
Deleting this workspace will permanently remove all associated workflows, logs,
and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{showTemplateChoice ? (
<div className='flex gap-2 py-2'>
<Button
onClick={() => handleTemplateAction('keep')}
className='h-9 flex-1 rounded-[8px]'
variant='outline'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Keep Templates'}
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Templates'}
</Button>
</div>
) : (
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name{' '}
<span className='font-semibold'>{workspaceToDelete?.name}</span> to confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder={workspaceToDelete?.name}
className='h-9 rounded-[8px]'
/>
</div>
)}
{!showTemplateChoice && (
<AlertDialogFooter className='flex'>
<Button
variant='outline'
className='h-9 w-full rounded-[8px]'
onClick={() => {
resetDeleteState()
setIsDeleteDialogOpen(false)
}}
>
Cancel
</Button>
<Button
onClick={(e) => {
e.preventDefault()
handleDeleteClick()
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={
isDeleting ||
deleteConfirmationName !== workspaceToDelete?.name ||
isCheckingTemplates
}
>
{isDeleting ? 'Deleting...' : isCheckingTemplates ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
)}
</AlertDialogContent>
</AlertDialog>
{/* Invite Modal */}
<InviteModal
open={showInviteMembers}

View File

@@ -1,20 +1,21 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lucide-react'
import { Search } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
import {
CreateMenu,
FloatingNavigation,
FolderTree,
HelpModal,
KeyboardShortcut,
KnowledgeBaseTags,
KnowledgeTags,
LogsFilters,
@@ -26,6 +27,7 @@ import {
WorkspaceSelector,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
import {
getKeyboardShortcutText,
useGlobalShortcuts,
@@ -40,109 +42,6 @@ const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Optimized auto-scroll hook for smooth drag operations
* Extracted outside component for better performance
*/
const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}
// Heights for dynamic calculation (in px)
const SIDEBAR_HEIGHTS = {
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
@@ -204,6 +103,7 @@ export function Sidebar() {
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
// Add sidebar collapsed state
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string
@@ -509,16 +409,22 @@ export function Sidebar() {
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
/**
* Confirm delete workspace
* Confirm delete workspace (called from regular deletion dialog)
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const deleteTemplates = templateAction === 'delete'
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
@@ -961,44 +867,6 @@ export function Sidebar() {
}
}, [stopScroll])
// Navigation items with their respective actions
const navigationItems = [
{
id: 'settings',
icon: Settings,
onClick: () => setShowSettings(true),
tooltip: 'Settings',
},
{
id: 'help',
icon: HelpCircle,
onClick: () => setShowHelp(true),
tooltip: 'Help',
},
{
id: 'logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{
id: 'knowledge',
icon: LibraryBig,
href: `/workspace/${workspaceId}/knowledge`,
tooltip: 'Knowledge',
active: pathname === `/workspace/${workspaceId}/knowledge`,
},
{
id: 'templates',
icon: Shapes,
href: `/workspace/${workspaceId}/templates`,
tooltip: 'Templates',
active: pathname === `/workspace/${workspaceId}/templates`,
},
]
return (
<>
{/* Main Sidebar - Overlay */}
@@ -1155,16 +1023,13 @@ export function Sidebar() {
)}
{/* Floating Navigation - Always visible */}
<div
className='pointer-events-auto fixed left-4 z-50 w-56'
style={{ bottom: `${navigationBottom}px` }}
>
<div className='flex items-center gap-1'>
{navigationItems.map((item) => (
<NavigationItem key={item.id} item={item} />
))}
</div>
</div>
<FloatingNavigation
workspaceId={workspaceId}
pathname={pathname}
onShowSettings={() => setShowSettings(true)}
onShowHelp={() => setShowHelp(true)}
bottom={navigationBottom}
/>
{/* Modals */}
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
@@ -1183,98 +1048,3 @@ export function Sidebar() {
</>
)
}
// Keyboard Shortcut Component
interface KeyboardShortcutProps {
shortcut: string
className?: string
}
const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
const parts = shortcut.split('+')
// Helper function to determine if a part is a symbol that should be larger
const isSymbol = (part: string) => {
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
}
return (
<kbd
className={cn(
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
className
)}
>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
{parts.map((part, index) => (
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
{part}
</span>
))}
</span>
</kbd>
)
}
// Navigation Item Component
interface NavigationItemProps {
item: {
id: string
icon: React.ElementType
onClick?: () => void
href?: string
tooltip: string
shortcut?: string
active?: boolean
disabled?: boolean
}
}
const NavigationItem = ({ item }: NavigationItemProps) => {
// Settings and help buttons get gray hover, others get purple hover
const isGrayHover = item.id === 'settings' || item.id === 'help'
const content = item.disabled ? (
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<item.icon className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover &&
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
)}
>
<item.icon className='h-4 w-4' />
</Button>
)
if (item.href && !item.disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a href={item.href} className='inline-block'>
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -0,0 +1,103 @@
import { useCallback, useRef } from 'react'
/**
* Optimized auto-scroll hook for smooth drag operations
*/
export const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}

View File

@@ -13,7 +13,7 @@ export default function WorkspaceRootLayout({ children }: WorkspaceRootLayoutPro
const user = session.data?.user
? {
id: session.data.user.id,
name: session.data.user.name,
name: session.data.user.name ?? undefined,
email: session.data.user.email,
}
: undefined

View File

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

View File

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

View File

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

View File

@@ -106,6 +106,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
layout: 'full',
required: true,
placeholder: 'Enter new summary for the issue',
dependsOn: ['issueKey'],
condition: { field: 'operation', value: ['update', 'write'] },
},
{
@@ -114,6 +115,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'long-input',
layout: 'full',
placeholder: 'Enter new description for the issue',
dependsOn: ['issueKey'],
condition: { field: 'operation', value: ['update', 'write'] },
},
],

View File

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

View File

@@ -32,7 +32,6 @@ export type SubBlockType =
| 'checkbox-list' // Multiple selection
| 'condition-input' // Conditional logic
| 'eval-input' // Evaluation input
| 'date-input' // Date input
| 'time-input' // Time input
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration

View File

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

View File

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

View 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

View File

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

View File

@@ -1,10 +1,12 @@
import { render } from '@react-email/components'
import {
BatchInvitationEmail,
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
ResetPasswordEmail,
} from '@/components/emails'
import { getBrandConfig } from '@/lib/branding/branding'
export async function renderOTPEmail(
otp: string,
@@ -65,6 +67,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,21 +90,26 @@ export function getEmailSubject(
| 'reset-password'
| 'invitation'
| 'batch-invitation'
| 'help-confirmation'
): string {
const brandName = getBrandConfig().name
switch (type) {
case 'sign-in':
return 'Sign in to Sim'
return `Sign in to ${brandName}`
case 'email-verification':
return 'Verify your email for Sim'
return `Verify your email for ${brandName}`
case 'forget-password':
return 'Reset your Sim password'
return `Reset your ${brandName} password`
case 'reset-password':
return 'Reset your Sim password'
return `Reset your ${brandName} password`
case 'invitation':
return "You've been invited to join a team on Sim"
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return "You've been invited to join a team and workspaces on Sim"
return `You've been invited to join a team and workspaces on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
default:
return 'Sim'
return brandName
}
}

View File

@@ -1,62 +0,0 @@
'use client'
import type * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }

View File

@@ -22,7 +22,6 @@ export {
BreadcrumbSeparator,
} from './breadcrumb'
export { Button, buttonVariants } from './button'
export { Calendar } from './calendar'
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './card'
export { Checkbox } from './checkbox'
export { CodeBlock } from './code-block'

View File

@@ -108,6 +108,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const [isConnecting, setIsConnecting] = useState(false)
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const initializedRef = useRef(false)
// Get current workflow ID from URL params
const params = useParams()
@@ -131,16 +132,16 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
// Helper function to generate a fresh socket token
const generateSocketToken = async (): Promise<string> => {
const tokenResponse = await fetch('/api/auth/socket-token', {
// Avoid overlapping token requests
const res = await fetch('/api/auth/socket-token', {
method: 'POST',
credentials: 'include',
headers: { 'cache-control': 'no-store' },
})
if (!tokenResponse.ok) {
throw new Error('Failed to generate socket token')
}
const { token } = await tokenResponse.json()
if (!res.ok) throw new Error('Failed to generate socket token')
const body = await res.json().catch(() => ({}))
const token = body?.token
if (!token || typeof token !== 'string') throw new Error('Invalid socket token')
return token
}
@@ -149,12 +150,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
if (!user?.id) return
// Only initialize if we don't have a socket and aren't already connecting
if (socket || isConnecting) {
if (initializedRef.current || socket || isConnecting) {
logger.info('Socket already exists or is connecting, skipping initialization')
return
}
logger.info('Initializing socket connection for user:', user.id)
initializedRef.current = true
setIsConnecting(true)
const initializeSocket = async () => {
@@ -178,17 +180,14 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
reconnectionDelay: 1000, // Start with 1 second delay
reconnectionDelayMax: 30000, // Max 30 second delay
timeout: 10000, // Back to original timeout
auth: (cb) => {
// Generate a fresh token for each connection attempt (including reconnections)
generateSocketToken()
.then((freshToken) => {
logger.info('Generated fresh token for connection attempt')
cb({ token: freshToken })
})
.catch((error) => {
logger.error('Failed to generate fresh token for connection:', error)
cb({ token: null }) // This will cause authentication to fail gracefully
})
auth: async (cb) => {
try {
const freshToken = await generateSocketToken()
cb({ token: freshToken })
} catch (error) {
logger.error('Failed to generate fresh token for connection:', error)
cb({ token: null })
}
},
})

19
apps/sim/db/consts.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Database-only constants used in schema definitions and migrations.
* These constants are independent of application logic to keep migrations container lightweight.
*/
/**
* Default free credits (in dollars) for new users
*/
export const DEFAULT_FREE_CREDITS = 10
/**
* Tag slots available for knowledge base documents and embeddings
*/
export const TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
/**
* Type for tag slot names
*/
export type TagSlot = (typeof TAG_SLOTS)[number]

View File

@@ -0,0 +1,5 @@
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_status" text DEFAULT 'pending' NOT NULL;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_started_at" timestamp;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_completed_at" timestamp;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_error" text;
CREATE INDEX IF NOT EXISTS "doc_processing_status_idx" ON "document" USING btree ("knowledge_base_id","processing_status");

View File

@@ -0,0 +1 @@
ALTER TABLE "templates" ALTER COLUMN "workflow_id" DROP NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -533,6 +533,13 @@
"when": 1755375658161,
"tag": "0076_damp_vector",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1755809024626,
"tag": "0077_rapid_chimera",
"breakpoints": true
}
]
}

View File

@@ -16,8 +16,7 @@ import {
uuid,
vector,
} from 'drizzle-orm/pg-core'
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { TAG_SLOTS } from '@/lib/constants/knowledge'
import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './consts'
// Custom tsvector type for full-text search
export const tsvector = customType<{
@@ -1062,9 +1061,7 @@ export const templates = pgTable(
'templates',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id),
workflowId: text('workflow_id').references(() => workflow.id),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),

View File

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

View File

@@ -368,6 +368,8 @@ export class AgentBlockHandler implements BlockHandler {
stream: streaming,
messages,
environmentVariables: context.environmentVariables || {},
reasoningEffort: inputs.reasoningEffort,
verbosity: inputs.verbosity,
}
}

View File

@@ -10,6 +10,8 @@ export interface AgentInputs {
apiKey?: string
azureEndpoint?: string
azureApiVersion?: string
reasoningEffort?: string
verbosity?: string
}
export interface ToolInput {

View File

@@ -27,6 +27,13 @@ export class TriggerBlockHandler implements BlockHandler {
): Promise<any> {
logger.info(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`)
// If this trigger block was initialized with a precomputed output in the execution context
// (e.g., webhook payload injected at init), return it as-is to preserve the raw shape.
const existingState = context.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
return existingState.output
}
// For trigger blocks, return the starter block's output which contains the workflow input
// This ensures webhook data like message, sender, chat, etc. are accessible
const starterBlock = context.workflow?.blocks?.find((b) => b.metadata?.id === 'starter')
@@ -36,9 +43,10 @@ export class TriggerBlockHandler implements BlockHandler {
const starterOutput = starterState.output
// Generic handling for webhook triggers - extract provider-specific data
// Check if this is a webhook execution with nested structure
// Check if this is a webhook execution
if (starterOutput.webhook?.data) {
const webhookData = starterOutput.webhook.data
const webhookData = starterOutput.webhook?.data || {}
const provider = webhookData.provider
logger.debug(`Processing webhook trigger for block ${block.id}`, {
@@ -46,7 +54,21 @@ export class TriggerBlockHandler implements BlockHandler {
blockType: block.metadata?.id,
})
// Extract the flattened properties that should be at root level
// Provider-specific early return for GitHub: expose raw payload at root
if (provider === 'github') {
const payloadSource = webhookData.payload || {}
return {
...payloadSource,
webhook: starterOutput.webhook,
}
}
// Provider-specific early return for Airtable: preserve raw shape entirely
if (provider === 'airtable') {
return starterOutput
}
// Extract the flattened properties that should be at root level (non-GitHub/Airtable)
const result: any = {
// Always keep the input at root level
input: starterOutput.input,
@@ -67,70 +89,17 @@ export class TriggerBlockHandler implements BlockHandler {
const providerData = starterOutput[provider]
for (const [key, value] of Object.entries(providerData)) {
// Special handling for GitHub provider - copy all properties
if (provider === 'github') {
// For GitHub, copy all properties (objects and primitives) to root level
// For other providers, keep existing logic (only copy objects)
if (typeof value === 'object' && value !== null) {
// Don't overwrite existing top-level properties
if (!result[key]) {
// Special handling for complex objects that might have enumeration issues
if (typeof value === 'object' && value !== null) {
try {
// Deep clone complex objects to avoid reference issues
result[key] = JSON.parse(JSON.stringify(value))
} catch (error) {
// If JSON serialization fails, try direct assignment
result[key] = value
}
} else {
result[key] = value
}
}
} else {
// For other providers, keep existing logic (only copy objects)
if (typeof value === 'object' && value !== null) {
// Don't overwrite existing top-level properties
if (!result[key]) {
result[key] = value
}
result[key] = value
}
}
}
// Keep nested structure for backwards compatibility
result[provider] = providerData
// Special handling for GitHub complex objects that might not be copied by the main loop
if (provider === 'github') {
// Comprehensive GitHub object extraction from multiple possible sources
const githubObjects = ['repository', 'sender', 'pusher', 'commits', 'head_commit']
for (const objName of githubObjects) {
// ALWAYS try to get the object, even if something exists (fix for conflicts)
let objectValue = null
// Source 1: Direct from provider data
if (providerData[objName]) {
objectValue = providerData[objName]
}
// Source 2: From webhook payload (raw GitHub webhook)
else if (starterOutput.webhook?.data?.payload?.[objName]) {
objectValue = starterOutput.webhook.data.payload[objName]
}
// Source 3: For commits, try parsing JSON string version if no object found
else if (objName === 'commits' && typeof result.commits === 'string') {
try {
objectValue = JSON.parse(result.commits)
} catch (e) {
// Keep as string if parsing fails
objectValue = result.commits
}
}
// FORCE the object to root level (removed the !result[objName] condition)
if (objectValue !== null && objectValue !== undefined) {
result[objName] = objectValue
}
}
}
}
// Pattern 2: Provider data directly in webhook.data (based on actual structure)

View File

@@ -118,7 +118,13 @@ export class WorkflowBlockHandler implements BlockHandler {
if ((mappedResult as any).success === false) {
const childError = (mappedResult as any).error || 'Unknown error'
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
const errorWithSpans = new Error(
`Error in child workflow "${childWorkflowName}": ${childError}`
) as any
// Attach trace spans and name for higher-level logging to consume
errorWithSpans.childTraceSpans = childTraceSpans
errorWithSpans.childWorkflowName = childWorkflowName
throw errorWithSpans
}
return mappedResult
@@ -306,11 +312,16 @@ export class WorkflowBlockHandler implements BlockHandler {
const success = childResult.success !== false
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
return {
const failure: Record<string, any> = {
success: false,
childWorkflowName,
error: childResult.error || 'Child workflow execution failed',
} as Record<string, any>
}
// Only include spans when present to keep output stable for callers/tests
if (Array.isArray(childTraceSpans) && childTraceSpans.length > 0) {
failure.childTraceSpans = childTraceSpans
}
return failure as Record<string, any>
}
let result = childResult
if (childResult?.output) {

View File

@@ -455,6 +455,14 @@ export class Executor {
success: false,
output: finalOutput,
error: 'Workflow execution was cancelled',
metadata: {
duration: Date.now() - startTime.getTime(),
startTime: context.metadata.startTime!,
workflowConnections: this.actualWorkflow.connections.map((conn: any) => ({
source: conn.source,
target: conn.target,
})),
},
logs: context.blockLogs,
}
}
@@ -503,6 +511,14 @@ export class Executor {
success: false,
output: finalOutput,
error: this.extractErrorMessage(error),
metadata: {
duration: Date.now() - startTime.getTime(),
startTime: context.metadata.startTime!,
workflowConnections: this.actualWorkflow.connections.map((conn: any) => ({
source: conn.source,
target: conn.target,
})),
},
logs: context.blockLogs,
}
} finally {
@@ -530,6 +546,14 @@ export class Executor {
success: false,
output: finalOutput,
error: 'Workflow execution was cancelled',
metadata: {
duration: Date.now() - new Date(context.metadata.startTime!).getTime(),
startTime: context.metadata.startTime!,
workflowConnections: this.actualWorkflow.connections.map((conn: any) => ({
source: conn.source,
target: conn.target,
})),
},
logs: context.blockLogs,
}
}
@@ -596,6 +620,14 @@ export class Executor {
success: false,
output: finalOutput,
error: this.extractErrorMessage(error),
metadata: {
duration: Date.now() - new Date(context.metadata.startTime!).getTime(),
startTime: context.metadata.startTime!,
workflowConnections: this.actualWorkflow.connections.map((conn: any) => ({
source: conn.source,
target: conn.target,
})),
},
logs: context.blockLogs,
}
}
@@ -771,7 +803,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
@@ -781,13 +813,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'
@@ -1730,6 +1774,11 @@ export class Executor {
blockLog.durationMs =
new Date(blockLog.endedAt).getTime() - new Date(blockLog.startedAt).getTime()
// If this error came from a child workflow execution, persist its trace spans on the log
if (block.metadata?.id === BlockType.WORKFLOW) {
this.attachChildWorkflowSpansToLog(blockLog, error)
}
// Log the error even if we'll continue execution through error path
context.blockLogs.push(blockLog)
@@ -1808,6 +1857,11 @@ export class Executor {
status: error.status || 500,
}
// Preserve child workflow spans on the block state so downstream logging can render them
if (block.metadata?.id === BlockType.WORKFLOW) {
this.attachChildWorkflowSpansToOutput(errorOutput, error)
}
// Set block state with error output
context.blockStates.set(blockId, {
output: errorOutput,
@@ -1852,6 +1906,39 @@ export class Executor {
}
}
/**
* Copies child workflow trace spans from an error object into a block log.
* Ensures consistent structure and avoids duplication of inline guards.
*/
private attachChildWorkflowSpansToLog(blockLog: BlockLog, error: unknown): void {
const spans = (
error as { childTraceSpans?: TraceSpan[]; childWorkflowName?: string } | null | undefined
)?.childTraceSpans
if (Array.isArray(spans) && spans.length > 0) {
blockLog.output = {
...(blockLog.output || {}),
childTraceSpans: spans,
childWorkflowName: (error as { childWorkflowName?: string } | null | undefined)
?.childWorkflowName,
}
}
}
/**
* Copies child workflow trace spans from an error object into a normalized output.
*/
private attachChildWorkflowSpansToOutput(output: NormalizedBlockOutput, error: unknown): void {
const spans = (
error as { childTraceSpans?: TraceSpan[]; childWorkflowName?: string } | null | undefined
)?.childTraceSpans
if (Array.isArray(spans) && spans.length > 0) {
output.childTraceSpans = spans
output.childWorkflowName = (
error as { childWorkflowName?: string } | null | undefined
)?.childWorkflowName
}
}
/**
* Activates error paths from a block that had an error.
* Checks for connections from the block's "error" handle and adds them to the active execution path.

View File

@@ -1,3 +1,4 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { BlockOutput } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
@@ -52,6 +53,9 @@ export interface NormalizedBlockOutput {
headers?: Record<string, string> // HTTP headers
// Error handling
error?: string // Error message if block execution failed
// Child workflow introspection (for workflow blocks)
childTraceSpans?: TraceSpan[]
childWorkflowName?: string
}
/**

View File

@@ -34,8 +34,8 @@ export function useUserPermissions(
const { data: session } = useSession()
const userPermissions = useMemo((): WorkspaceUserPermissions => {
// If still loading or no session, return safe defaults
if (permissionsLoading || !session?.user?.email) {
const sessionEmail = session?.user?.email
if (permissionsLoading || !sessionEmail) {
return {
canRead: false,
canEdit: false,
@@ -48,13 +48,13 @@ export function useUserPermissions(
// Find current user in workspace permissions (case-insensitive)
const currentUser = workspacePermissions?.users?.find(
(user) => user.email.toLowerCase() === session.user.email.toLowerCase()
(user) => user.email.toLowerCase() === sessionEmail.toLowerCase()
)
// If user not found in workspace, they have no permissions
if (!currentUser) {
logger.warn('User not found in workspace permissions', {
userEmail: session.user.email,
userEmail: sessionEmail,
hasPermissions: !!workspacePermissions,
userCount: workspacePermissions?.users?.length || 0,
})

View File

@@ -1,8 +1,16 @@
import { useContext } from 'react'
import { stripeClient } from '@better-auth/stripe/client'
import { emailOTPClient, genericOAuthClient, organizationClient } from 'better-auth/client/plugins'
import {
customSessionClient,
emailOTPClient,
genericOAuthClient,
organizationClient,
} from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '@/lib/auth'
import { env, getEnv } from '@/lib/env'
import { isDev, isProd } from '@/lib/environment'
import { SessionContext, type SessionHookResult } from '@/lib/session-context'
export function getBaseURL() {
let baseURL
@@ -25,6 +33,7 @@ export const client = createAuthClient({
plugins: [
emailOTPClient(),
genericOAuthClient(),
customSessionClient<typeof auth>(),
// Only include Stripe client in production
...(isProd
? [
@@ -37,7 +46,17 @@ export const client = createAuthClient({
],
})
export const { useSession, useActiveOrganization } = client
export function useSession(): SessionHookResult {
const ctx = useContext(SessionContext)
if (!ctx) {
throw new Error(
'SessionProvider is not mounted. Wrap your app with <SessionProvider> in app/layout.tsx.'
)
}
return ctx
}
export const { useActiveOrganization } = client
export const useSubscription = () => {
// In development, provide mock implementations

View File

@@ -4,6 +4,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import {
createAuthMiddleware,
customSession,
emailOTP,
genericOAuth,
oneTimeToken,
@@ -11,7 +12,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,11 +21,12 @@ 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 { getFromEmailAddress } from '@/lib/email/utils'
import { quickValidateEmail } from '@/lib/email/validation'
import { env, isTruthy } from '@/lib/env'
import { isBillingEnabled, isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
@@ -45,22 +46,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 +150,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: getFromEmailAddress(),
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}`)
}
},
},
@@ -223,6 +209,10 @@ export const auth = betterAuth({
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
}),
customSession(async ({ user, session }) => ({
user,
session,
})),
emailOTP({
sendVerificationOTP: async (data: {
email: string
@@ -252,8 +242,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: getFromEmailAddress(),
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 +264,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 +461,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 +476,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 +498,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 +522,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 +537,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 +559,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,133 +1269,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]
// Store the original user ID before we change the referenceId
const originalUserId = subscription.referenceId
// First, sync usage limits for the purchasing user with their new plan
try {
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
await syncUsageLimitsFromSubscription(originalUserId)
logger.info(
'Usage limits synced for purchasing user before organization creation',
{
userId: originalUserId,
}
)
} catch (error) {
logger.error('Failed to sync usage limits for purchasing user', {
userId: originalUserId,
error,
})
}
// 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,
})
}
// Initialize billing period for the user/organization
// Initialize billing period and sync usage limits
try {
const { initializeBillingPeriod } = await import(
'@/lib/billing/core/billing-periods'
)
const { syncSubscriptionUsageLimits } = await import(
'@/lib/billing/team-management'
)
// Note: Usage limits are already synced above for team plan users
// For non-team plans, sync usage limits using the referenceId (which is the user ID)
if (subscription.plan !== 'team') {
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
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') {
@@ -1447,15 +1329,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,
@@ -1551,12 +1447,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: getFromEmailAddress(),
emailType: 'transactional',
})
if (!result.success) {
logger.error('Failed to send organization invitation email:', result.message)
}
} catch (error) {
logger.error('Error sending invitation email', { error })
}
@@ -1584,8 +1485,9 @@ export const auth = betterAuth({
// Server-side auth helpers
export async function getSession() {
const hdrs = await headers()
return await auth.api.getSession({
headers: await headers(),
headers: hdrs,
})
}

View File

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

View File

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

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

View File

@@ -1,5 +1,5 @@
import { and, count, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { quickValidateEmail } from '@/lib/email/validation'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
@@ -33,8 +33,8 @@ export async function validateSeatAvailability(
additionalSeats = 1
): Promise<SeatValidationResult> {
try {
// Get organization subscription
const subscription = await getHighestPrioritySubscription(organizationId)
// Get organization subscription directly (referenceId = organizationId)
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {
return {
@@ -71,7 +71,10 @@ export async function validateSeatAvailability(
// For enterprise plans, check metadata for custom seat allowances
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata = JSON.parse(subscription.metadata)
const metadata =
typeof subscription.metadata === 'string'
? JSON.parse(subscription.metadata)
: subscription.metadata
if (metadata.maxSeats) {
maxSeats = metadata.maxSeats
}
@@ -142,8 +145,8 @@ export async function getOrganizationSeatInfo(
return null
}
// Get subscription
const subscription = await getHighestPrioritySubscription(organizationId)
// Get organization subscription directly (referenceId = organizationId)
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {
return null
@@ -163,7 +166,10 @@ export async function getOrganizationSeatInfo(
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata = JSON.parse(subscription.metadata)
const metadata =
typeof subscription.metadata === 'string'
? JSON.parse(subscription.metadata)
: subscription.metadata
if (metadata.maxSeats) {
maxSeats = metadata.maxSeats
}
@@ -282,8 +288,8 @@ export async function updateOrganizationSeats(
updatedBy: string
): Promise<{ success: boolean; error?: string }> {
try {
// Get current subscription
const subscriptionRecord = await getHighestPrioritySubscription(organizationId)
// Get current organization subscription directly (referenceId = organizationId)
const subscriptionRecord = await getOrganizationSubscription(organizationId)
if (!subscriptionRecord) {
return { success: false, error: 'No active subscription found' }

View File

@@ -1,5 +1,14 @@
import { getEnv } from '@/lib/env'
export interface ThemeColors {
primaryColor?: string
primaryHoverColor?: string
secondaryColor?: string
accentColor?: string
accentHoverColor?: string
backgroundColor?: string
}
export interface BrandConfig {
name: string
logoUrl?: string
@@ -9,6 +18,7 @@ export interface BrandConfig {
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
theme?: ThemeColors
}
/**
@@ -23,6 +33,29 @@ const defaultConfig: BrandConfig = {
documentationUrl: undefined,
termsUrl: undefined,
privacyUrl: undefined,
theme: {
primaryColor: '#701ffc',
primaryHoverColor: '#802fff',
secondaryColor: '#6518e6',
accentColor: '#9d54ff',
accentHoverColor: '#a66fff',
backgroundColor: '#0c0c0c',
},
}
const getThemeColors = (): ThemeColors => {
return {
primaryColor: getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR') || defaultConfig.theme?.primaryColor,
primaryHoverColor:
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR') || defaultConfig.theme?.primaryHoverColor,
secondaryColor:
getEnv('NEXT_PUBLIC_BRAND_SECONDARY_COLOR') || defaultConfig.theme?.secondaryColor,
accentColor: getEnv('NEXT_PUBLIC_BRAND_ACCENT_COLOR') || defaultConfig.theme?.accentColor,
accentHoverColor:
getEnv('NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR') || defaultConfig.theme?.accentHoverColor,
backgroundColor:
getEnv('NEXT_PUBLIC_BRAND_BACKGROUND_COLOR') || defaultConfig.theme?.backgroundColor,
}
}
/**
@@ -39,6 +72,7 @@ export const getBrandConfig = (): BrandConfig => {
documentationUrl: getEnv('NEXT_PUBLIC_DOCUMENTATION_URL') || defaultConfig.documentationUrl,
termsUrl: getEnv('NEXT_PUBLIC_TERMS_URL') || defaultConfig.termsUrl,
privacyUrl: getEnv('NEXT_PUBLIC_PRIVACY_URL') || defaultConfig.privacyUrl,
theme: getThemeColors(),
}
}

View File

@@ -0,0 +1,29 @@
export function generateThemeCSS(): string {
const cssVars: string[] = []
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR) {
cssVars.push(`--brand-primary-hex: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR) {
cssVars.push(`--brand-primary-hover-hex: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_SECONDARY_COLOR) {
cssVars.push(`--brand-secondary-hex: ${process.env.NEXT_PUBLIC_BRAND_SECONDARY_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR) {
cssVars.push(`--brand-accent-hex: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR) {
cssVars.push(`--brand-accent-hover-hex: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
cssVars.push(`--brand-background-hex: ${process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR};`)
}
return cssVars.length > 0 ? `:root { ${cssVars.join(' ')} }` : ''
}

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