From f5b4ab0c8b7b8097ca911c0bec6701032723ba31 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Fri, 2 Jan 2026 12:21:24 -0800
Subject: [PATCH 01/21] progress on cred sets
---
apps/sim/app/api/auth/oauth/token/route.ts | 47 ++-
apps/sim/app/api/auth/oauth/utils.ts | 100 +++++-
.../api/credential-sets/[id]/invite/route.ts | 165 +++++++++
.../api/credential-sets/[id]/members/route.ts | 114 ++++++
.../sim/app/api/credential-sets/[id]/route.ts | 154 ++++++++
.../api/credential-sets/invitations/route.ts | 52 +++
.../credential-sets/invite/[token]/route.ts | 150 ++++++++
.../api/credential-sets/memberships/route.ts | 39 ++
apps/sim/app/api/credential-sets/route.ts | 153 ++++++++
.../app/credential-account/[token]/page.tsx | 167 +++++++++
.../credential-selector.tsx | 128 ++++++-
.../credential-sets/credential-sets.tsx | 335 ++++++++++++++++++
.../settings-modal/components/index.ts | 1 +
.../settings-modal/settings-modal.tsx | 4 +
apps/sim/background/webhook-execution.ts | 3 +
apps/sim/blocks/blocks/webhook.ts | 2 +
apps/sim/blocks/types.ts | 2 +
apps/sim/executor/constants.ts | 2 +
apps/sim/executor/execution/snapshot.ts | 3 +-
apps/sim/executor/types.ts | 1 +
apps/sim/executor/variables/resolver.ts | 2 +
.../variables/resolvers/credential-set.ts | 42 +++
apps/sim/hooks/queries/credential-sets.ts | 163 +++++++++
apps/sim/hooks/use-webhook-management.ts | 23 +-
apps/sim/lib/webhooks/processor.ts | 81 ++++-
apps/sim/lib/webhooks/utils.server.ts | 102 ++++--
apps/sim/tools/index.ts | 21 +-
apps/sim/tools/types.ts | 4 +-
packages/db/schema.ts | 94 +++++
29 files changed, 2078 insertions(+), 76 deletions(-)
create mode 100644 apps/sim/app/api/credential-sets/[id]/invite/route.ts
create mode 100644 apps/sim/app/api/credential-sets/[id]/members/route.ts
create mode 100644 apps/sim/app/api/credential-sets/[id]/route.ts
create mode 100644 apps/sim/app/api/credential-sets/invitations/route.ts
create mode 100644 apps/sim/app/api/credential-sets/invite/[token]/route.ts
create mode 100644 apps/sim/app/api/credential-sets/memberships/route.ts
create mode 100644 apps/sim/app/api/credential-sets/route.ts
create mode 100644 apps/sim/app/credential-account/[token]/page.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
create mode 100644 apps/sim/executor/variables/resolvers/credential-set.ts
create mode 100644 apps/sim/hooks/queries/credential-sets.ts
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 603f5c6b0b..325f5d87ce 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -4,7 +4,7 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
-import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
+import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -12,12 +12,17 @@ const logger = createLogger('OAuthTokenAPI')
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
-const tokenRequestSchema = z.object({
- credentialId: z
- .string({ required_error: 'Credential ID is required' })
- .min(1, 'Credential ID is required'),
- workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
-})
+const tokenRequestSchema = z
+ .object({
+ credentialId: z.string().min(1).optional(),
+ credentialAccountUserId: z.string().min(1).optional(),
+ providerId: z.string().min(1).optional(),
+ workflowId: z.string().min(1).nullish(),
+ })
+ .refine(
+ (data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
+ 'Either credentialId or (credentialAccountUserId + providerId) is required'
+ )
const tokenQuerySchema = z.object({
credentialId: z
@@ -58,9 +63,31 @@ export async function POST(request: NextRequest) {
)
}
- const { credentialId, workflowId } = parseResult.data
+ const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
+
+ if (credentialAccountUserId && providerId) {
+ logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
+ credentialAccountUserId,
+ providerId,
+ })
+
+ const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
+ if (!accessToken) {
+ return NextResponse.json(
+ {
+ error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
+ },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json({ accessToken }, { status: 200 })
+ }
+
+ if (!credentialId) {
+ return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
+ }
- // We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
@@ -70,7 +97,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
- // Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
@@ -78,7 +104,6 @@ export async function POST(request: NextRequest) {
}
try {
- // Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
let instanceUrl: string | undefined
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index cb9176e989..60e999242b 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
-import { account, workflow } from '@sim/db/schema'
+import { account, credentialSetMember, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, desc, eq } from 'drizzle-orm'
+import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
@@ -335,3 +335,99 @@ export async function refreshTokenIfNeeded(
throw error
}
}
+
+export interface CredentialSetCredential {
+ userId: string
+ credentialId: string
+ accessToken: string
+ providerId: string
+}
+
+export async function getCredentialsForCredentialSet(
+ credentialSetId: string,
+ providerId: string
+): Promise {
+ const members = await db
+ .select({ userId: credentialSetMember.userId })
+ .from(credentialSetMember)
+ .where(
+ and(
+ eq(credentialSetMember.credentialSetId, credentialSetId),
+ eq(credentialSetMember.status, 'active')
+ )
+ )
+
+ if (members.length === 0) {
+ logger.warn(`No active members found for credential set ${credentialSetId}`)
+ return []
+ }
+
+ const userIds = members.map((m) => m.userId)
+
+ const credentials = await db
+ .select({
+ id: account.id,
+ userId: account.userId,
+ providerId: account.providerId,
+ accessToken: account.accessToken,
+ refreshToken: account.refreshToken,
+ accessTokenExpiresAt: account.accessTokenExpiresAt,
+ })
+ .from(account)
+ .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId)))
+
+ const results: CredentialSetCredential[] = []
+
+ for (const cred of credentials) {
+ const now = new Date()
+ const tokenExpiry = cred.accessTokenExpiresAt
+ const shouldRefresh =
+ !!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now))
+
+ let accessToken = cred.accessToken
+
+ if (shouldRefresh && cred.refreshToken) {
+ try {
+ const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken)
+
+ if (refreshResult) {
+ accessToken = refreshResult.accessToken
+
+ const updateData: Record = {
+ accessToken: refreshResult.accessToken,
+ accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000),
+ updatedAt: new Date(),
+ }
+
+ if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) {
+ updateData.refreshToken = refreshResult.refreshToken
+ }
+
+ await db.update(account).set(updateData).where(eq(account.id, cred.id))
+
+ logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`)
+ }
+ } catch (error) {
+ logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, {
+ error: error instanceof Error ? error.message : String(error),
+ })
+ continue
+ }
+ }
+
+ if (accessToken) {
+ results.push({
+ userId: cred.userId,
+ credentialId: cred.id,
+ accessToken,
+ providerId,
+ })
+ }
+ }
+
+ logger.info(
+ `Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}`
+ )
+
+ return results
+}
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
new file mode 100644
index 0000000000..147cf49363
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
@@ -0,0 +1,165 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetInvitation, member } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSetInvite')
+
+const createInviteSchema = z.object({
+ email: z.string().email().optional(),
+})
+
+async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
+ const [set] = await db
+ .select({
+ id: credentialSet.id,
+ organizationId: credentialSet.organizationId,
+ name: credentialSet.name,
+ })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (!set) return null
+
+ const [membership] = await db
+ .select({ role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
+ .limit(1)
+
+ if (!membership) return null
+
+ return { set, role: membership.role }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ const invitations = await db
+ .select()
+ .from(credentialSetInvitation)
+ .where(eq(credentialSetInvitation.credentialSetId, id))
+
+ return NextResponse.json({ invitations })
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin') {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ const body = await req.json()
+ const { email } = createInviteSchema.parse(body)
+
+ const token = crypto.randomUUID()
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + 7)
+
+ const invitation = {
+ id: crypto.randomUUID(),
+ credentialSetId: id,
+ email: email || null,
+ token,
+ invitedBy: session.user.id,
+ status: 'pending' as const,
+ expiresAt,
+ createdAt: new Date(),
+ }
+
+ await db.insert(credentialSetInvitation).values(invitation)
+
+ const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}`
+
+ logger.info('Created credential set invitation', {
+ credentialSetId: id,
+ invitationId: invitation.id,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({
+ invitation: {
+ ...invitation,
+ inviteUrl,
+ },
+ })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
+ }
+ logger.error('Error creating invitation', error)
+ return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
+ }
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ const { searchParams } = new URL(req.url)
+ const invitationId = searchParams.get('invitationId')
+
+ if (!invitationId) {
+ return NextResponse.json({ error: 'invitationId is required' }, { status: 400 })
+ }
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin') {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ await db
+ .update(credentialSetInvitation)
+ .set({ status: 'cancelled' })
+ .where(
+ and(
+ eq(credentialSetInvitation.id, invitationId),
+ eq(credentialSetInvitation.credentialSetId, id)
+ )
+ )
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error('Error cancelling invitation', error)
+ return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
new file mode 100644
index 0000000000..4da50d0c89
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -0,0 +1,114 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSetMembers')
+
+async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
+ const [set] = await db
+ .select({
+ id: credentialSet.id,
+ organizationId: credentialSet.organizationId,
+ })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (!set) return null
+
+ const [membership] = await db
+ .select({ role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
+ .limit(1)
+
+ if (!membership) return null
+
+ return { set, role: membership.role }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ const members = await db
+ .select({
+ id: credentialSetMember.id,
+ userId: credentialSetMember.userId,
+ status: credentialSetMember.status,
+ joinedAt: credentialSetMember.joinedAt,
+ createdAt: credentialSetMember.createdAt,
+ userName: user.name,
+ userEmail: user.email,
+ userImage: user.image,
+ })
+ .from(credentialSetMember)
+ .leftJoin(user, eq(credentialSetMember.userId, user.id))
+ .where(eq(credentialSetMember.credentialSetId, id))
+
+ return NextResponse.json({ members })
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ const { searchParams } = new URL(req.url)
+ const memberId = searchParams.get('memberId')
+
+ if (!memberId) {
+ return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
+ }
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin') {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ const [memberToRemove] = await db
+ .select()
+ .from(credentialSetMember)
+ .where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)))
+ .limit(1)
+
+ if (!memberToRemove) {
+ return NextResponse.json({ error: 'Member not found' }, { status: 404 })
+ }
+
+ await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
+
+ logger.info('Removed member from credential set', {
+ credentialSetId: id,
+ memberId,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error('Error removing member from credential set', error)
+ return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts
new file mode 100644
index 0000000000..1b805b0f7f
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/[id]/route.ts
@@ -0,0 +1,154 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetMember, member } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSet')
+
+const updateCredentialSetSchema = z.object({
+ name: z.string().trim().min(1).max(100).optional(),
+ description: z.string().max(500).nullable().optional(),
+})
+
+async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
+ const [set] = await db
+ .select({
+ id: credentialSet.id,
+ organizationId: credentialSet.organizationId,
+ name: credentialSet.name,
+ description: credentialSet.description,
+ createdBy: credentialSet.createdBy,
+ createdAt: credentialSet.createdAt,
+ updatedAt: credentialSet.updatedAt,
+ })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (!set) return null
+
+ const [membership] = await db
+ .select({ role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
+ .limit(1)
+
+ if (!membership) return null
+
+ return { set, role: membership.role }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ return NextResponse.json({ credentialSet: result.set })
+}
+
+export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin') {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ const body = await req.json()
+ const updates = updateCredentialSetSchema.parse(body)
+
+ if (updates.name) {
+ const existingSet = await db
+ .select({ id: credentialSet.id })
+ .from(credentialSet)
+ .where(
+ and(
+ eq(credentialSet.organizationId, result.set.organizationId),
+ eq(credentialSet.name, updates.name)
+ )
+ )
+ .limit(1)
+
+ if (existingSet.length > 0 && existingSet[0].id !== id) {
+ return NextResponse.json(
+ { error: 'A credential set with this name already exists' },
+ { status: 409 }
+ )
+ }
+ }
+
+ await db
+ .update(credentialSet)
+ .set({
+ ...updates,
+ updatedAt: new Date(),
+ })
+ .where(eq(credentialSet.id, id))
+
+ const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
+
+ return NextResponse.json({ credentialSet: updated })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
+ }
+ logger.error('Error updating credential set', error)
+ return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 })
+ }
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin') {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id))
+ await db.delete(credentialSet).where(eq(credentialSet.id, id))
+
+ logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error('Error deleting credential set', error)
+ return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts
new file mode 100644
index 0000000000..bd2b0d7afe
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/invitations/route.ts
@@ -0,0 +1,52 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, gt, or } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSetInvitations')
+
+export async function GET() {
+ const session = await getSession()
+
+ if (!session?.user?.id || !session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const invitations = await db
+ .select({
+ invitationId: credentialSetInvitation.id,
+ token: credentialSetInvitation.token,
+ status: credentialSetInvitation.status,
+ expiresAt: credentialSetInvitation.expiresAt,
+ createdAt: credentialSetInvitation.createdAt,
+ credentialSetId: credentialSet.id,
+ credentialSetName: credentialSet.name,
+ organizationId: organization.id,
+ organizationName: organization.name,
+ invitedByName: user.name,
+ invitedByEmail: user.email,
+ })
+ .from(credentialSetInvitation)
+ .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
+ .innerJoin(organization, eq(credentialSet.organizationId, organization.id))
+ .leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id))
+ .where(
+ and(
+ or(
+ eq(credentialSetInvitation.email, session.user.email),
+ eq(credentialSetInvitation.email, null)
+ ),
+ eq(credentialSetInvitation.status, 'pending'),
+ gt(credentialSetInvitation.expiresAt, new Date())
+ )
+ )
+
+ return NextResponse.json({ invitations })
+ } catch (error) {
+ logger.error('Error fetching credential set invitations', error)
+ return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
new file mode 100644
index 0000000000..fc9d50817c
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
@@ -0,0 +1,150 @@
+import { db } from '@sim/db'
+import {
+ credentialSet,
+ credentialSetInvitation,
+ credentialSetMember,
+ organization,
+} from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSetInviteToken')
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
+ const { token } = await params
+
+ const [invitation] = await db
+ .select({
+ id: credentialSetInvitation.id,
+ credentialSetId: credentialSetInvitation.credentialSetId,
+ email: credentialSetInvitation.email,
+ status: credentialSetInvitation.status,
+ expiresAt: credentialSetInvitation.expiresAt,
+ credentialSetName: credentialSet.name,
+ organizationId: credentialSet.organizationId,
+ organizationName: organization.name,
+ })
+ .from(credentialSetInvitation)
+ .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
+ .innerJoin(organization, eq(credentialSet.organizationId, organization.id))
+ .where(eq(credentialSetInvitation.token, token))
+ .limit(1)
+
+ if (!invitation) {
+ return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
+ }
+
+ if (invitation.status !== 'pending') {
+ return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
+ }
+
+ if (new Date() > invitation.expiresAt) {
+ await db
+ .update(credentialSetInvitation)
+ .set({ status: 'expired' })
+ .where(eq(credentialSetInvitation.id, invitation.id))
+
+ return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
+ }
+
+ return NextResponse.json({
+ invitation: {
+ credentialSetName: invitation.credentialSetName,
+ organizationName: invitation.organizationName,
+ email: invitation.email,
+ },
+ })
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
+ const { token } = await params
+
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+
+ try {
+ const [invitation] = await db
+ .select()
+ .from(credentialSetInvitation)
+ .where(eq(credentialSetInvitation.token, token))
+ .limit(1)
+
+ if (!invitation) {
+ return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
+ }
+
+ if (invitation.status !== 'pending') {
+ return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
+ }
+
+ if (new Date() > invitation.expiresAt) {
+ await db
+ .update(credentialSetInvitation)
+ .set({ status: 'expired' })
+ .where(eq(credentialSetInvitation.id, invitation.id))
+
+ return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
+ }
+
+ if (invitation.email && invitation.email !== session.user.email) {
+ return NextResponse.json({ error: 'Email does not match invitation' }, { status: 400 })
+ }
+
+ const existingMember = await db
+ .select()
+ .from(credentialSetMember)
+ .where(
+ and(
+ eq(credentialSetMember.credentialSetId, invitation.credentialSetId),
+ eq(credentialSetMember.userId, session.user.id)
+ )
+ )
+ .limit(1)
+
+ if (existingMember.length > 0) {
+ return NextResponse.json(
+ { error: 'Already a member of this credential set' },
+ { status: 409 }
+ )
+ }
+
+ const now = new Date()
+ await db.insert(credentialSetMember).values({
+ id: crypto.randomUUID(),
+ credentialSetId: invitation.credentialSetId,
+ userId: session.user.id,
+ status: 'active',
+ joinedAt: now,
+ invitedBy: invitation.invitedBy,
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ await db
+ .update(credentialSetInvitation)
+ .set({
+ status: 'accepted',
+ acceptedAt: now,
+ acceptedByUserId: session.user.id,
+ })
+ .where(eq(credentialSetInvitation.id, invitation.id))
+
+ logger.info('Accepted credential set invitation', {
+ invitationId: invitation.id,
+ credentialSetId: invitation.credentialSetId,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({
+ success: true,
+ credentialSetId: invitation.credentialSetId,
+ })
+ } catch (error) {
+ logger.error('Error accepting invitation', error)
+ return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts
new file mode 100644
index 0000000000..33a60c2af5
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/memberships/route.ts
@@ -0,0 +1,39 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetMember, organization } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSetMemberships')
+
+export async function GET() {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const memberships = await db
+ .select({
+ membershipId: credentialSetMember.id,
+ status: credentialSetMember.status,
+ joinedAt: credentialSetMember.joinedAt,
+ credentialSetId: credentialSet.id,
+ credentialSetName: credentialSet.name,
+ credentialSetDescription: credentialSet.description,
+ organizationId: organization.id,
+ organizationName: organization.name,
+ })
+ .from(credentialSetMember)
+ .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
+ .innerJoin(organization, eq(credentialSet.organizationId, organization.id))
+ .where(eq(credentialSetMember.userId, session.user.id))
+
+ return NextResponse.json({ memberships })
+ } catch (error) {
+ logger.error('Error fetching credential set memberships', error)
+ return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts
new file mode 100644
index 0000000000..1ddeef24a9
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/route.ts
@@ -0,0 +1,153 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, count, desc, eq } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+
+const logger = createLogger('CredentialSets')
+
+const createCredentialSetSchema = z.object({
+ organizationId: z.string().min(1),
+ name: z.string().trim().min(1).max(100),
+ description: z.string().max(500).optional(),
+})
+
+export async function GET(req: Request) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { searchParams } = new URL(req.url)
+ const organizationId = searchParams.get('organizationId')
+
+ if (!organizationId) {
+ return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
+ }
+
+ const membership = await db
+ .select({ id: member.id, role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
+ .limit(1)
+
+ if (membership.length === 0) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const sets = await db
+ .select({
+ id: credentialSet.id,
+ name: credentialSet.name,
+ description: credentialSet.description,
+ createdBy: credentialSet.createdBy,
+ createdAt: credentialSet.createdAt,
+ updatedAt: credentialSet.updatedAt,
+ creatorName: user.name,
+ creatorEmail: user.email,
+ })
+ .from(credentialSet)
+ .leftJoin(user, eq(credentialSet.createdBy, user.id))
+ .where(eq(credentialSet.organizationId, organizationId))
+ .orderBy(desc(credentialSet.createdAt))
+
+ const setsWithCounts = await Promise.all(
+ sets.map(async (set) => {
+ const [memberCount] = await db
+ .select({ count: count() })
+ .from(credentialSetMember)
+ .where(
+ and(
+ eq(credentialSetMember.credentialSetId, set.id),
+ eq(credentialSetMember.status, 'active')
+ )
+ )
+
+ return {
+ ...set,
+ memberCount: memberCount?.count ?? 0,
+ }
+ })
+ )
+
+ return NextResponse.json({ credentialSets: setsWithCounts })
+}
+
+export async function POST(req: Request) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const body = await req.json()
+ const { organizationId, name, description } = createCredentialSetSchema.parse(body)
+
+ const membership = await db
+ .select({ id: member.id, role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
+ .limit(1)
+
+ if (membership.length === 0 || membership[0].role !== 'admin') {
+ return NextResponse.json(
+ { error: 'Admin permissions required to create credential sets' },
+ { status: 403 }
+ )
+ }
+
+ const orgExists = await db
+ .select({ id: organization.id })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1)
+
+ if (orgExists.length === 0) {
+ return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
+ }
+
+ const existingSet = await db
+ .select({ id: credentialSet.id })
+ .from(credentialSet)
+ .where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
+ .limit(1)
+
+ if (existingSet.length > 0) {
+ return NextResponse.json(
+ { error: 'A credential set with this name already exists' },
+ { status: 409 }
+ )
+ }
+
+ const now = new Date()
+ const newCredentialSet = {
+ id: crypto.randomUUID(),
+ organizationId,
+ name,
+ description: description || null,
+ createdBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ }
+
+ await db.insert(credentialSet).values(newCredentialSet)
+
+ logger.info('Created credential set', {
+ credentialSetId: newCredentialSet.id,
+ organizationId,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
+ }
+ logger.error('Error creating credential set', error)
+ return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
new file mode 100644
index 0000000000..1eb0e6382e
--- /dev/null
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { AlertCircle, CheckCircle2, Loader2, Shield } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import { Button } from '@/components/emcn'
+import { useSession } from '@/lib/auth/auth-client'
+
+interface InvitationInfo {
+ credentialSetName: string
+ organizationName: string
+ email: string | null
+}
+
+export default function CredentialAccountInvitePage() {
+ const params = useParams()
+ const router = useRouter()
+ const token = params.token as string
+
+ const { data: session, isPending: sessionLoading } = useSession()
+
+ const [invitation, setInvitation] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [accepting, setAccepting] = useState(false)
+ const [accepted, setAccepted] = useState(false)
+
+ useEffect(() => {
+ async function fetchInvitation() {
+ try {
+ const res = await fetch(`/api/credential-sets/invite/${token}`)
+ if (!res.ok) {
+ const data = await res.json()
+ setError(data.error || 'Failed to load invitation')
+ return
+ }
+ const data = await res.json()
+ setInvitation(data.invitation)
+ } catch {
+ setError('Failed to load invitation')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchInvitation()
+ }, [token])
+
+ const handleAccept = useCallback(async () => {
+ if (!session?.user?.id) {
+ router.push(`/login?callbackUrl=${encodeURIComponent(`/credential-account/${token}`)}`)
+ return
+ }
+
+ setAccepting(true)
+ try {
+ const res = await fetch(`/api/credential-sets/invite/${token}`, {
+ method: 'POST',
+ })
+
+ if (!res.ok) {
+ const data = await res.json()
+ setError(data.error || 'Failed to accept invitation')
+ return
+ }
+
+ setAccepted(true)
+ } catch {
+ setError('Failed to accept invitation')
+ } finally {
+ setAccepting(false)
+ }
+ }, [session?.user?.id, token, router])
+
+ if (loading || sessionLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Unable to load invitation
+
+
{error}
+
+
+ )
+ }
+
+ if (accepted) {
+ return (
+
+
+
+
Welcome!
+
+ You've successfully joined {invitation?.credentialSetName}. Connect your OAuth
+ credentials in Settings → Integrations.
+
+
router.push('/w')}>
+ Go to Dashboard
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
Join Credential Set
+
+ You've been invited to join{' '}
+
+ {invitation?.credentialSetName}
+ {' '}
+ by {invitation?.organizationName}
+
+
+
+
+ {session?.user ? (
+ <>
+
+ Logged in as{' '}
+ {session.user.email}
+
+
+ {accepting ? (
+ <>
+
+ Joining...
+ >
+ ) : (
+ 'Accept Invitation'
+ )}
+
+ >
+ ) : (
+ <>
+
+ Sign in or create an account to accept this invitation
+
+
+ Continue
+
+ >
+ )}
+
+
+
+ By joining, you agree to share your OAuth credentials with this credential set for use in
+ automated workflows.
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index d902200dd2..7533b838dc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -2,8 +2,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ExternalLink } from 'lucide-react'
+import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
+import { getSubscriptionStatus } from '@/lib/billing/client'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -15,12 +16,17 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
+import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
+import { useOrganizations } from '@/hooks/queries/organization'
+import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
+const CREDENTIAL_SET_PREFIX = 'credentialSet:'
+
interface CredentialSelectorProps {
blockId: string
subBlock: SubBlockConfig
@@ -45,6 +51,19 @@ export function CredentialSelector({
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId || ''
+ const supportsCredentialSets = subBlock.supportsCredentialSets || false
+
+ const { data: organizationsData } = useOrganizations()
+ const { data: subscriptionData } = useSubscriptionData()
+ const activeOrganization = organizationsData?.activeOrganization
+ const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
+ const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
+ const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id
+
+ const { data: credentialSets = [] } = useCredentialSets(
+ activeOrganization?.id,
+ canUseCredentialSets
+ )
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const hasDependencies = dependsOn.length > 0
@@ -52,7 +71,12 @@ export function CredentialSelector({
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
- const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
+ const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
+ const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET_PREFIX)
+ const selectedId = isCredentialSetSelected ? '' : rawSelectedId
+ const selectedCredentialSetId = isCredentialSetSelected
+ ? rawSelectedId.slice(CREDENTIAL_SET_PREFIX.length)
+ : ''
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(serviceId) as OAuthProvider,
@@ -87,11 +111,17 @@ export function CredentialSelector({
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
+ const selectedCredentialSet = useMemo(
+ () => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
+ [credentialSets, selectedCredentialSetId]
+ )
+
const resolvedLabel = useMemo(() => {
+ if (selectedCredentialSet) return selectedCredentialSet.name
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
return ''
- }, [selectedCredential, isForeign])
+ }, [selectedCredentialSet, selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
@@ -148,6 +178,15 @@ export function CredentialSelector({
[isPreview, setStoreValue]
)
+ const handleCredentialSetSelect = useCallback(
+ (credentialSetId: string) => {
+ if (isPreview) return
+ setStoreValue(`${CREDENTIAL_SET_PREFIX}${credentialSetId}`)
+ setIsEditing(false)
+ },
+ [isPreview, setStoreValue]
+ )
+
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
@@ -176,7 +215,43 @@ export function CredentialSelector({
.join(' ')
}, [])
- const comboboxOptions = useMemo(() => {
+ const { comboboxOptions, comboboxGroups } = useMemo(() => {
+ if (canUseCredentialSets && credentialSets.length > 0) {
+ const groups = []
+
+ groups.push({
+ section: 'Credential Sets',
+ items: credentialSets.map((cs) => ({
+ label: `${cs.name} (${cs.memberCount} members)`,
+ value: `${CREDENTIAL_SET_PREFIX}${cs.id}`,
+ })),
+ })
+
+ const credentialItems = credentials.map((cred) => ({
+ label: cred.name,
+ value: cred.id,
+ }))
+
+ if (credentialItems.length > 0) {
+ groups.push({
+ section: 'My Credentials',
+ items: credentialItems,
+ })
+ } else if (!isCredentialSetSelected) {
+ groups.push({
+ section: 'My Credentials',
+ items: [
+ {
+ label: `Connect ${getProviderName(provider)} account`,
+ value: '__connect_account__',
+ },
+ ],
+ })
+ }
+
+ return { comboboxOptions: [], comboboxGroups: groups }
+ }
+
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
@@ -189,14 +264,32 @@ export function CredentialSelector({
})
}
- return options
- }, [credentials, provider, getProviderName])
+ return { comboboxOptions: options, comboboxGroups: undefined }
+ }, [
+ credentials,
+ provider,
+ getProviderName,
+ canUseCredentialSets,
+ credentialSets,
+ isCredentialSetSelected,
+ ])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
+ if (isCredentialSetSelected && selectedCredentialSet) {
+ return (
+
+ )
+ }
+
return (
@@ -205,7 +298,13 @@ export function CredentialSelector({
{inputValue}
)
- }, [getProviderIcon, inputValue, selectedCredentialProvider])
+ }, [
+ getProviderIcon,
+ inputValue,
+ selectedCredentialProvider,
+ isCredentialSetSelected,
+ selectedCredentialSet,
+ ])
const handleComboboxChange = useCallback(
(value: string) => {
@@ -214,6 +313,16 @@ export function CredentialSelector({
return
}
+ if (value.startsWith(CREDENTIAL_SET_PREFIX)) {
+ const credentialSetId = value.slice(CREDENTIAL_SET_PREFIX.length)
+ const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
+ if (matchedSet) {
+ setInputValue(matchedSet.name)
+ handleCredentialSetSelect(credentialSetId)
+ return
+ }
+ }
+
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
@@ -224,15 +333,16 @@ export function CredentialSelector({
setIsEditing(true)
setInputValue(value)
},
- [credentials, handleAddCredential, handleSelect]
+ [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
return (
+
+
+
+
+ )
+}
+
+export function CredentialSets() {
+ const { data: session } = useSession()
+ const { data: organizationsData } = useOrganizations()
+ const { data: subscriptionData } = useSubscriptionData()
+
+ const activeOrganization = organizationsData?.activeOrganization
+ const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
+ const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
+ const userRole = getUserRole(activeOrganization, session?.user?.email)
+ const isAdmin = userRole === 'admin' || userRole === 'owner'
+ const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id
+
+ const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships()
+ const { data: invitations = [], isPending: invitationsLoading } = useCredentialSetInvitations()
+ const { data: ownedSets = [], isPending: ownedSetsLoading } = useCredentialSets(
+ activeOrganization?.id,
+ canManageCredentialSets
+ )
+
+ const acceptInvitation = useAcceptCredentialSetInvitation()
+ const createCredentialSet = useCreateCredentialSet()
+ const createInvitation = useCreateCredentialSetInvitation()
+
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [showInviteModal, setShowInviteModal] = useState(false)
+ const [selectedSetId, setSelectedSetId] = useState
(null)
+ const [newSetName, setNewSetName] = useState('')
+ const [newSetDescription, setNewSetDescription] = useState('')
+ const [inviteEmail, setInviteEmail] = useState('')
+ const [copiedLink, setCopiedLink] = useState(false)
+
+ const handleAcceptInvitation = useCallback(
+ async (token: string) => {
+ try {
+ await acceptInvitation.mutateAsync(token)
+ } catch (error) {
+ logger.error('Failed to accept invitation', error)
+ }
+ },
+ [acceptInvitation]
+ )
+
+ const handleCreateCredentialSet = useCallback(async () => {
+ if (!newSetName.trim() || !activeOrganization?.id) return
+ try {
+ await createCredentialSet.mutateAsync({
+ organizationId: activeOrganization.id,
+ name: newSetName.trim(),
+ description: newSetDescription.trim() || undefined,
+ })
+ setShowCreateModal(false)
+ setNewSetName('')
+ setNewSetDescription('')
+ } catch (error) {
+ logger.error('Failed to create credential set', error)
+ }
+ }, [newSetName, newSetDescription, activeOrganization?.id, createCredentialSet])
+
+ const handleCreateInvite = useCallback(async () => {
+ if (!selectedSetId) return
+ try {
+ const result = await createInvitation.mutateAsync({
+ credentialSetId: selectedSetId,
+ email: inviteEmail.trim() || undefined,
+ })
+ const inviteUrl = result.invitation?.inviteUrl
+ if (inviteUrl) {
+ await navigator.clipboard.writeText(inviteUrl)
+ setCopiedLink(true)
+ setTimeout(() => setCopiedLink(false), 2000)
+ }
+ setShowInviteModal(false)
+ setInviteEmail('')
+ setSelectedSetId(null)
+ } catch (error) {
+ logger.error('Failed to create invitation', error)
+ }
+ }, [selectedSetId, inviteEmail, createInvitation])
+
+ if (membershipsLoading || invitationsLoading) {
+ return
+ }
+
+ const activeMemberships = memberships.filter((m) => m.status === 'active')
+ const hasNoContent =
+ invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0
+
+ return (
+
+ {hasNoContent && !canManageCredentialSets && (
+
+
+ You're not a member of any credential sets yet.
+
+
+ When someone invites you to a credential set, it will appear here.
+
+
+ )}
+
+ {invitations.length > 0 && (
+
+
Pending Invitations
+ {invitations.map((invitation) => (
+
+
+
+ {invitation.credentialSetName}
+
+
+ {invitation.organizationName}
+
+
+
handleAcceptInvitation(invitation.token)}
+ disabled={acceptInvitation.isPending}
+ >
+ {acceptInvitation.isPending ? (
+
+ ) : (
+ 'Accept'
+ )}
+
+
+ ))}
+
+ )}
+
+ {activeMemberships.length > 0 && (
+
+
My Memberships
+ {activeMemberships.map((membership) => (
+
+
+
+ {membership.credentialSetName}
+
+
+ {membership.organizationName}
+
+
+
+ ))}
+
+ )}
+
+ {canManageCredentialSets && (
+
+
+
Manage
+
setShowCreateModal(true)}>
+
+ Create
+
+
+
+ {ownedSetsLoading ? (
+ <>
+
+
+ >
+ ) : ownedSets.length === 0 ? (
+
+
+ No credential sets created yet
+
+
+ ) : (
+ ownedSets.map((set) => (
+
+
+
+ {set.name}
+
+
+ {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+
+
+
{
+ setSelectedSetId(set.id)
+ setShowInviteModal(true)
+ }}
+ >
+ Invite
+
+
+ ))
+ )}
+
+ )}
+
+
+
+ Create Credential Set
+
+
+
+
+ setShowCreateModal(false)}>
+ Cancel
+
+
+ {createCredentialSet.isPending ? (
+
+ ) : (
+ 'Create'
+ )}
+
+
+
+
+
+
+
+ Invite to Credential Set
+
+
+
+ Email (optional)
+ setInviteEmail(e.target.value)}
+ placeholder='Leave empty for a shareable link'
+ />
+
+
+ An invite link will be copied to your clipboard.
+
+
+
+
+ setShowInviteModal(false)}>
+ Cancel
+
+
+ {createInvitation.isPending ? (
+
+ ) : copiedLink ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Invite Link
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
index 2c4d03228d..af86138a71 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts
@@ -1,6 +1,7 @@
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
+export { CredentialSets } from './credential-sets/credential-sets'
export { CustomTools } from './custom-tools/custom-tools'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index 1392a59601..37c876350e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -32,6 +32,7 @@ import {
ApiKeys,
BYOK,
Copilot,
+ CredentialSets,
CustomTools,
EnvironmentVariables,
FileUploads,
@@ -63,6 +64,7 @@ type SettingsSection =
| 'environment'
| 'template-profile'
| 'integrations'
+ | 'credential-sets'
| 'apikeys'
| 'byok'
| 'files'
@@ -98,6 +100,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
const allNavigationItems: NavigationItem[] = [
{ id: 'general', label: 'General', icon: Settings, section: 'account' },
{ id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
+ { id: 'credential-sets', label: 'Credential Sets', icon: Users, section: 'account' },
{
id: 'subscription',
label: 'Subscription',
@@ -462,6 +465,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
+ {activeSection === 'credential-sets' && }
{activeSection === 'apikeys' && }
{activeSection === 'files' && }
{isBillingEnabled && activeSection === 'subscription' && }
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index e632ec2340..e895c1b405 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -94,6 +94,7 @@ export type WebhookExecutionPayload = {
testMode?: boolean
executionTarget?: 'deployed' | 'live'
credentialId?: string
+ credentialAccountUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -240,6 +241,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
+ credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -487,6 +489,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
+ credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
diff --git a/apps/sim/blocks/blocks/webhook.ts b/apps/sim/blocks/blocks/webhook.ts
index a7a9a1a737..493ce0850f 100644
--- a/apps/sim/blocks/blocks/webhook.ts
+++ b/apps/sim/blocks/blocks/webhook.ts
@@ -98,6 +98,7 @@ export const WebhookBlock: BlockConfig = {
placeholder: 'Select Gmail account',
condition: { field: 'webhookProvider', value: 'gmail' },
required: true,
+ supportsCredentialSets: true,
},
{
id: 'outlookCredential',
@@ -114,6 +115,7 @@ export const WebhookBlock: BlockConfig = {
placeholder: 'Select Microsoft account',
condition: { field: 'webhookProvider', value: 'outlook' },
required: true,
+ supportsCredentialSets: true,
},
{
id: 'webhookConfig',
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index 572593f677..7a9b61f86f 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -251,6 +251,8 @@ export interface SubBlockConfig {
// OAuth specific properties - serviceId is the canonical identifier for OAuth services
serviceId?: string
requiredScopes?: string[]
+ // Whether this credential selector supports credential sets (for trigger blocks)
+ supportsCredentialSets?: boolean
// File selector specific properties
mimeType?: string
// File upload specific properties
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index fd7c44436c..973db5b75e 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -112,6 +112,7 @@ export const REFERENCE = {
LOOP: 'loop',
PARALLEL: 'parallel',
VARIABLE: 'variable',
+ CREDENTIAL_SET: 'credentialSet',
},
} as const
@@ -119,6 +120,7 @@ export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.LOOP,
REFERENCE.PREFIX.PARALLEL,
REFERENCE.PREFIX.VARIABLE,
+ REFERENCE.PREFIX.CREDENTIAL_SET,
] as const
export const LOOP_REFERENCE = {
diff --git a/apps/sim/executor/execution/snapshot.ts b/apps/sim/executor/execution/snapshot.ts
index dfa0d1cc37..1dc0a9ed23 100644
--- a/apps/sim/executor/execution/snapshot.ts
+++ b/apps/sim/executor/execution/snapshot.ts
@@ -16,12 +16,13 @@ export interface ExecutionMetadata {
isClientSession?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
+ credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record
edges: Edge[]
loops?: Record
parallels?: Record
- deploymentVersionId?: string // ID of deployment version if this is deployed state
+ deploymentVersionId?: string
}
}
diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts
index f33b49195f..9c266e8b40 100644
--- a/apps/sim/executor/types.ts
+++ b/apps/sim/executor/types.ts
@@ -124,6 +124,7 @@ export interface ExecutionMetadata {
isDebugSession?: boolean
context?: ExecutionContext
workflowConnections?: Array<{ source: string; target: string }>
+ credentialAccountUserId?: string
status?: 'running' | 'paused' | 'completed'
pausePoints?: string[]
resumeChain?: {
diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts
index 980708931b..f4140b65f2 100644
--- a/apps/sim/executor/variables/resolver.ts
+++ b/apps/sim/executor/variables/resolver.ts
@@ -4,6 +4,7 @@ import type { ExecutionState, LoopScope } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation'
import { BlockResolver } from '@/executor/variables/resolvers/block'
+import { CredentialSetResolver } from '@/executor/variables/resolvers/credential-set'
import { EnvResolver } from '@/executor/variables/resolvers/env'
import { LoopResolver } from '@/executor/variables/resolvers/loop'
import { ParallelResolver } from '@/executor/variables/resolvers/parallel'
@@ -27,6 +28,7 @@ export class VariableResolver {
new LoopResolver(workflow),
new ParallelResolver(workflow),
new WorkflowResolver(workflowVariables),
+ new CredentialSetResolver(),
new EnvResolver(),
this.blockResolver,
]
diff --git a/apps/sim/executor/variables/resolvers/credential-set.ts b/apps/sim/executor/variables/resolvers/credential-set.ts
new file mode 100644
index 0000000000..00560b7749
--- /dev/null
+++ b/apps/sim/executor/variables/resolvers/credential-set.ts
@@ -0,0 +1,42 @@
+import { createLogger } from '@sim/logger'
+import { parseReferencePath, REFERENCE } from '@/executor/constants'
+import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference'
+
+const logger = createLogger('CredentialSetResolver')
+
+export const CREDENTIAL_SET_USER_PREFIX = 'credentialSetUser:'
+
+export class CredentialSetResolver implements Resolver {
+ canResolve(reference: string): boolean {
+ const parts = parseReferencePath(reference)
+ if (parts.length < 1) return false
+
+ const type = parts[0]
+ return type === REFERENCE.PREFIX.CREDENTIAL_SET
+ }
+
+ resolve(reference: string, context: ResolutionContext): any {
+ const parts = parseReferencePath(reference)
+ if (parts.length < 2) {
+ logger.warn('Invalid credential set reference - missing property', { reference })
+ return undefined
+ }
+
+ const [_, property] = parts
+
+ if (property === 'currentUser') {
+ const credentialAccountUserId = context.executionContext.metadata?.credentialAccountUserId
+ if (!credentialAccountUserId) {
+ logger.warn(
+ 'credentialSet.currentUser referenced but no credentialAccountUserId in execution context',
+ { reference }
+ )
+ return undefined
+ }
+ return `${CREDENTIAL_SET_USER_PREFIX}${credentialAccountUserId}`
+ }
+
+ logger.warn('Unknown credential set property', { property, reference })
+ return undefined
+ }
+}
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
new file mode 100644
index 0000000000..cc61d2bac3
--- /dev/null
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -0,0 +1,163 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { fetchJson } from '@/hooks/selectors/helpers'
+
+export interface CredentialSet {
+ id: string
+ name: string
+ description: string | null
+ createdBy: string
+ createdAt: string
+ updatedAt: string
+ creatorName: string | null
+ creatorEmail: string | null
+ memberCount: number
+}
+
+export interface CredentialSetMembership {
+ membershipId: string
+ status: string
+ joinedAt: string | null
+ credentialSetId: string
+ credentialSetName: string
+ credentialSetDescription: string | null
+ organizationId: string
+ organizationName: string
+}
+
+export interface CredentialSetInvitation {
+ invitationId: string
+ token: string
+ status: string
+ expiresAt: string
+ createdAt: string
+ credentialSetId: string
+ credentialSetName: string
+ organizationId: string
+ organizationName: string
+ invitedByName: string | null
+ invitedByEmail: string | null
+}
+
+interface CredentialSetsResponse {
+ credentialSets?: CredentialSet[]
+}
+
+interface MembershipsResponse {
+ memberships?: CredentialSetMembership[]
+}
+
+interface InvitationsResponse {
+ invitations?: CredentialSetInvitation[]
+}
+
+export const credentialSetKeys = {
+ all: ['credentialSets'] as const,
+ list: (organizationId?: string) => ['credentialSets', 'list', organizationId ?? 'none'] as const,
+ detail: (id?: string) => ['credentialSets', 'detail', id ?? 'none'] as const,
+ memberships: () => ['credentialSets', 'memberships'] as const,
+ invitations: () => ['credentialSets', 'invitations'] as const,
+}
+
+export async function fetchCredentialSets(organizationId: string): Promise {
+ if (!organizationId) return []
+ const data = await fetchJson('/api/credential-sets', {
+ searchParams: { organizationId },
+ })
+ return data.credentialSets ?? []
+}
+
+export function useCredentialSets(organizationId?: string, enabled = true) {
+ return useQuery({
+ queryKey: credentialSetKeys.list(organizationId),
+ queryFn: () => fetchCredentialSets(organizationId ?? ''),
+ enabled: Boolean(organizationId) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useCredentialSetMemberships() {
+ return useQuery({
+ queryKey: credentialSetKeys.memberships(),
+ queryFn: async () => {
+ const data = await fetchJson('/api/credential-sets/memberships')
+ return data.memberships ?? []
+ },
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useCredentialSetInvitations() {
+ return useQuery({
+ queryKey: credentialSetKeys.invitations(),
+ queryFn: async () => {
+ const data = await fetchJson('/api/credential-sets/invitations')
+ return data.invitations ?? []
+ },
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useAcceptCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (token: string) => {
+ const response = await fetch(`/api/credential-sets/invite/${token}`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to accept invitation')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() })
+ },
+ })
+}
+
+export function useCreateCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { organizationId: string; name: string; description?: string }) => {
+ const response = await fetch('/api/credential-sets', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to create credential set')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) })
+ },
+ })
+}
+
+export function useCreateCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; email?: string }) => {
+ const response = await fetch(`/api/credential-sets/${data.credentialSetId}/invite`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: data.email }),
+ })
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to create invitation')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.all })
+ },
+ })
+}
diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts
index 3e81c35ced..a10382989c 100644
--- a/apps/sim/hooks/use-webhook-management.ts
+++ b/apps/sim/hooks/use-webhook-management.ts
@@ -10,6 +10,8 @@ import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('useWebhookManagement')
+const CREDENTIAL_SET_PREFIX = 'credentialSet:'
+
interface UseWebhookManagementProps {
blockId: string
triggerId?: string
@@ -220,9 +222,18 @@ export function useWebhookManagement({
}
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
+
+ const isCredentialSet =
+ selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX)
+ const credentialSetId = isCredentialSet
+ ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
+ : undefined
+ const credentialId = isCredentialSet ? undefined : selectedCredentialId
+
const webhookConfig = {
...(triggerConfig || {}),
- ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
triggerId: effectiveTriggerId,
}
@@ -279,13 +290,21 @@ export function useWebhookManagement({
): Promise => {
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
+ const isCredentialSet =
+ selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX)
+ const credentialSetId = isCredentialSet
+ ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
+ : undefined
+ const credentialId = isCredentialSet ? undefined : selectedCredentialId
+
const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerConfig: {
...triggerConfig,
- ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
triggerId: effectiveTriggerId,
},
}),
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index b197a8ef18..b6a941be31 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -13,6 +13,7 @@ import {
validateMicrosoftTeamsSignature,
verifyProviderWebhook,
} from '@/lib/webhooks/utils.server'
+import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
@@ -719,11 +720,12 @@ export async function queueWebhookExecution(
}
}
- // Extract credentialId from webhook config for credential-based webhooks
+ // Extract credentialId or credentialSetId from webhook config
const providerConfig = (foundWebhook.providerConfig as Record) || {}
const credentialId = providerConfig.credentialId as string | undefined
+ const credentialSetId = providerConfig.credentialSetId as string | undefined
- const payload = {
+ const basePayload = {
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
userId: foundWorkflow.userId,
@@ -734,25 +736,72 @@ export async function queueWebhookExecution(
blockId: foundWebhook.blockId,
testMode: options.testMode,
executionTarget: options.executionTarget,
- ...(credentialId ? { credentialId } : {}),
}
- if (isTriggerDevEnabled) {
- const handle = await tasks.trigger('webhook-execution', payload)
- logger.info(
- `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
- handle.id
- } for ${foundWebhook.provider} webhook`
+ if (credentialSetId) {
+ const credentials = await getCredentialsForCredentialSet(
+ credentialSetId,
+ foundWebhook.provider
)
- } else {
- void executeWebhookJob(payload).catch((error) => {
- logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
- })
+
+ if (credentials.length === 0) {
+ logger.warn(
+ `[${options.requestId}] No valid credentials found for credential set ${credentialSetId}, provider ${foundWebhook.provider}`
+ )
+ return NextResponse.json(
+ { error: 'No valid credentials in credential set' },
+ { status: 400 }
+ )
+ }
+
logger.info(
- `[${options.requestId}] Queued direct ${
- options.testMode ? 'TEST ' : ''
- }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
+ `[${options.requestId}] Fan-out: Queuing ${credentials.length} executions for credential set ${credentialSetId}`
)
+
+ for (const cred of credentials) {
+ const fanOutPayload = {
+ ...basePayload,
+ credentialId: cred.credentialId,
+ credentialAccountUserId: cred.userId,
+ }
+
+ if (isTriggerDevEnabled) {
+ const handle = await tasks.trigger('webhook-execution', fanOutPayload)
+ logger.info(
+ `[${options.requestId}] Queued fan-out execution ${handle.id} for user ${cred.userId}`
+ )
+ } else {
+ void executeWebhookJob(fanOutPayload).catch((error) => {
+ logger.error(
+ `[${options.requestId}] Direct fan-out execution failed for user ${cred.userId}`,
+ error
+ )
+ })
+ }
+ }
+ } else {
+ const payload = {
+ ...basePayload,
+ ...(credentialId ? { credentialId } : {}),
+ }
+
+ if (isTriggerDevEnabled) {
+ const handle = await tasks.trigger('webhook-execution', payload)
+ logger.info(
+ `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
+ handle.id
+ } for ${foundWebhook.provider} webhook`
+ )
+ } else {
+ void executeWebhookJob(payload).catch((error) => {
+ logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
+ })
+ logger.info(
+ `[${options.requestId}] Queued direct ${
+ options.testMode ? 'TEST ' : ''
+ }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
+ )
+ }
}
if (foundWebhook.provider === 'microsoft-teams') {
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index 49e69649df..063bc93140 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -2373,28 +2373,46 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
try {
const providerConfig = (webhookData.providerConfig as Record) || {}
const credentialId: string | undefined = providerConfig.credentialId
+ const credentialSetId: string | undefined = providerConfig.credentialSetId
- if (!credentialId) {
- logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`)
- return false
- }
-
- // Get userId from credential
- const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
- if (rows.length === 0) {
+ if (!credentialId && !credentialSetId) {
logger.error(
- `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
+ `[${requestId}] Missing credentialId or credentialSetId for Gmail webhook ${webhookData.id}`
)
return false
}
- const effectiveUserId = rows[0].userId
- const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
- if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
+ let effectiveUserId: string | undefined
+
+ if (credentialSetId) {
+ const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils')
+ const credentials = await getCredentialsForCredentialSet(credentialSetId, 'google-email')
+ if (credentials.length === 0) {
+ logger.error(
+ `[${requestId}] No Gmail credentials found in credential set ${credentialSetId}`
+ )
+ return false
+ }
+ logger.info(
+ `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Gmail credentials`
)
- return false
+ } else {
+ const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
+ if (rows.length === 0) {
+ logger.error(
+ `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
+ )
+ return false
+ }
+
+ effectiveUserId = rows[0].userId
+ const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
+ if (!accessToken) {
+ logger.error(
+ `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
+ )
+ return false
+ }
}
const maxEmailsPerPoll =
@@ -2414,8 +2432,9 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
.set({
providerConfig: {
...providerConfig,
- userId: effectiveUserId,
+ ...(effectiveUserId ? { userId: effectiveUserId } : {}),
...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
@@ -2456,28 +2475,46 @@ export async function configureOutlookPolling(
try {
const providerConfig = (webhookData.providerConfig as Record) || {}
const credentialId: string | undefined = providerConfig.credentialId
+ const credentialSetId: string | undefined = providerConfig.credentialSetId
- if (!credentialId) {
- logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`)
- return false
- }
-
- // Get userId from credential
- const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
- if (rows.length === 0) {
+ if (!credentialId && !credentialSetId) {
logger.error(
- `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
+ `[${requestId}] Missing credentialId or credentialSetId for Outlook webhook ${webhookData.id}`
)
return false
}
- const effectiveUserId = rows[0].userId
- const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
- if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
+ let effectiveUserId: string | undefined
+
+ if (credentialSetId) {
+ const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils')
+ const credentials = await getCredentialsForCredentialSet(credentialSetId, 'outlook')
+ if (credentials.length === 0) {
+ logger.error(
+ `[${requestId}] No Outlook credentials found in credential set ${credentialSetId}`
+ )
+ return false
+ }
+ logger.info(
+ `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Outlook credentials`
)
- return false
+ } else {
+ const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
+ if (rows.length === 0) {
+ logger.error(
+ `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
+ )
+ return false
+ }
+
+ effectiveUserId = rows[0].userId
+ const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
+ if (!accessToken) {
+ logger.error(
+ `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
+ )
+ return false
+ }
}
const providerCfg = (webhookData.providerConfig as Record) || {}
@@ -2489,8 +2526,9 @@ export async function configureOutlookPolling(
.set({
providerConfig: {
...providerCfg,
- userId: effectiveUserId,
+ ...(effectiveUserId ? { userId: effectiveUserId } : {}),
...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
maxEmailsPerPoll:
typeof providerCfg.maxEmailsPerPoll === 'string'
? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index f92f451807..af918c8b4e 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -5,6 +5,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { parseMcpToolId } from '@/lib/mcp/utils'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
+import { CREDENTIAL_SET_USER_PREFIX } from '@/executor/variables/resolvers/credential-set'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types'
@@ -254,8 +255,24 @@ export async function executeTool(
const baseUrl = getBaseUrl()
// Prepare the token payload
- const tokenPayload: OAuthTokenPayload = {
- credentialId: contextParams.credential,
+ const tokenPayload: OAuthTokenPayload = {}
+ const credentialValue = contextParams.credential as string
+
+ if (credentialValue.startsWith(CREDENTIAL_SET_USER_PREFIX)) {
+ const credentialAccountUserId = credentialValue.slice(CREDENTIAL_SET_USER_PREFIX.length)
+ const providerId = tool?.oauth?.provider
+ if (!providerId) {
+ throw new Error(
+ `Cannot resolve credential set user without providerId for tool ${toolId}`
+ )
+ }
+ tokenPayload.credentialAccountUserId = credentialAccountUserId
+ tokenPayload.providerId = providerId
+ logger.info(
+ `[${requestId}] Using credential set user: ${credentialAccountUserId}, provider: ${providerId}`
+ )
+ } else {
+ tokenPayload.credentialId = credentialValue
}
// Add workflowId if it exists in params, context, or executionContext
diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts
index 324f254e09..544a9c83e5 100644
--- a/apps/sim/tools/types.ts
+++ b/apps/sim/tools/types.ts
@@ -122,7 +122,9 @@ export interface TableRow {
}
export interface OAuthTokenPayload {
- credentialId: string
+ credentialId?: string
+ credentialAccountUserId?: string
+ providerId?: string
workflowId?: string
}
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 692fb11497..ac01cacddf 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -1777,3 +1777,97 @@ export const usageLog = pgTable(
workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId),
})
)
+
+export const credentialSet = pgTable(
+ 'credential_set',
+ {
+ id: text('id').primaryKey(),
+ organizationId: text('organization_id')
+ .notNull()
+ .references(() => organization.id, { onDelete: 'cascade' }),
+ name: text('name').notNull(),
+ description: text('description'),
+ createdBy: text('created_by')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+ },
+ (table) => ({
+ organizationIdIdx: index('credential_set_organization_id_idx').on(table.organizationId),
+ createdByIdx: index('credential_set_created_by_idx').on(table.createdBy),
+ orgNameUnique: uniqueIndex('credential_set_org_name_unique').on(
+ table.organizationId,
+ table.name
+ ),
+ })
+)
+
+export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [
+ 'active',
+ 'pending',
+ 'revoked',
+ 'credentials_missing',
+])
+
+export const credentialSetMember = pgTable(
+ 'credential_set_member',
+ {
+ id: text('id').primaryKey(),
+ credentialSetId: text('credential_set_id')
+ .notNull()
+ .references(() => credentialSet.id, { onDelete: 'cascade' }),
+ userId: text('user_id')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ status: credentialSetMemberStatusEnum('status').notNull().default('pending'),
+ joinedAt: timestamp('joined_at'),
+ invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+ },
+ (table) => ({
+ credentialSetIdIdx: index('credential_set_member_set_id_idx').on(table.credentialSetId),
+ userIdIdx: index('credential_set_member_user_id_idx').on(table.userId),
+ uniqueMembership: uniqueIndex('credential_set_member_unique').on(
+ table.credentialSetId,
+ table.userId
+ ),
+ statusIdx: index('credential_set_member_status_idx').on(table.status),
+ })
+)
+
+export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [
+ 'pending',
+ 'accepted',
+ 'expired',
+ 'cancelled',
+])
+
+export const credentialSetInvitation = pgTable(
+ 'credential_set_invitation',
+ {
+ id: text('id').primaryKey(),
+ credentialSetId: text('credential_set_id')
+ .notNull()
+ .references(() => credentialSet.id, { onDelete: 'cascade' }),
+ email: text('email'),
+ token: text('token').notNull().unique(),
+ invitedBy: text('invited_by')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ status: credentialSetInvitationStatusEnum('status').notNull().default('pending'),
+ expiresAt: timestamp('expires_at').notNull(),
+ acceptedAt: timestamp('accepted_at'),
+ acceptedByUserId: text('accepted_by_user_id').references(() => user.id, {
+ onDelete: 'set null',
+ }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ },
+ (table) => ({
+ credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId),
+ tokenIdx: index('credential_set_invitation_token_idx').on(table.token),
+ statusIdx: index('credential_set_invitation_status_idx').on(table.status),
+ expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt),
+ })
+)
From d908a287081df6e667edfcf0b5049bca46502d7b Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Mon, 5 Jan 2026 16:34:42 -0800
Subject: [PATCH 02/21] fix credential set system
---
apps/sim/app/api/auth/oauth/token/route.ts | 27 +-
apps/sim/app/api/auth/oauth/utils.ts | 11 +-
.../api/credential-sets/[id]/members/route.ts | 71 ++-
.../sim/app/api/credential-sets/[id]/route.ts | 2 +
.../api/credential-sets/invitations/route.ts | 4 +-
.../credential-sets/invite/[token]/route.ts | 34 ++
apps/sim/app/api/credential-sets/route.ts | 24 +-
apps/sim/app/api/webhooks/[id]/route.ts | 65 ++-
apps/sim/app/api/webhooks/route.ts | 151 ++++++
.../app/api/webhooks/trigger/[path]/route.ts | 204 +++++---
.../app/credential-account/[token]/page.tsx | 2 +-
.../credential-selector.tsx | 31 +-
.../credential-sets/credential-sets.tsx | 469 ++++++++++++++++--
apps/sim/background/webhook-execution.ts | 11 +-
apps/sim/executor/constants.ts | 14 +-
apps/sim/executor/variables/resolver.ts | 2 -
.../variables/resolvers/credential-set.ts | 42 --
apps/sim/hooks/queries/credential-sets.ts | 68 ++-
apps/sim/hooks/use-webhook-management.ts | 17 +-
.../sim/lib/webhooks/gmail-polling-service.ts | 9 +-
apps/sim/lib/webhooks/processor.ts | 169 ++++---
apps/sim/lib/webhooks/utils.server.ts | 439 ++++++++++++----
apps/sim/tools/index.ts | 22 +-
apps/sim/triggers/airtable/webhook.ts | 1 +
apps/sim/triggers/constants.ts | 1 +
apps/sim/triggers/gmail/poller.ts | 23 +
apps/sim/triggers/jira/utils.ts | 1 +
.../triggers/microsoftteams/chat_webhook.ts | 1 +
apps/sim/triggers/outlook/poller.ts | 17 +
.../webflow/collection_item_changed.ts | 1 +
.../webflow/collection_item_created.ts | 1 +
.../webflow/collection_item_deleted.ts | 1 +
apps/sim/triggers/webflow/form_submission.ts | 1 +
packages/db/schema.ts | 7 +-
34 files changed, 1539 insertions(+), 404 deletions(-)
delete mode 100644 apps/sim/executor/variables/resolvers/credential-set.ts
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 325f5d87ce..f5c8d7b617 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -71,17 +71,23 @@ export async function POST(request: NextRequest) {
providerId,
})
- const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
- if (!accessToken) {
- return NextResponse.json(
- {
- error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
- },
- { status: 404 }
- )
- }
+ try {
+ const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
+ if (!accessToken) {
+ return NextResponse.json(
+ {
+ error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
+ },
+ { status: 404 }
+ )
+ }
- return NextResponse.json({ accessToken }, { status: 200 })
+ return NextResponse.json({ accessToken }, { status: 200 })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to get OAuth token'
+ logger.warn(`[${requestId}] OAuth token error: ${message}`)
+ return NextResponse.json({ error: message }, { status: 403 })
+ }
}
if (!credentialId) {
@@ -170,7 +176,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
- // Get the credential from the database
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 60e999242b..08dd16fdff 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -105,10 +105,10 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
+ scope: account.scope,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
- // Always use the most recently updated credential for this provider
.orderBy(desc(account.updatedAt))
.limit(1)
@@ -347,6 +347,8 @@ export async function getCredentialsForCredentialSet(
credentialSetId: string,
providerId: string
): Promise {
+ logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`)
+
const members = await db
.select({ userId: credentialSetMember.userId })
.from(credentialSetMember)
@@ -357,12 +359,15 @@ export async function getCredentialsForCredentialSet(
)
)
+ logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`)
+
if (members.length === 0) {
logger.warn(`No active members found for credential set ${credentialSetId}`)
return []
}
const userIds = members.map((m) => m.userId)
+ logger.debug(`Member user IDs: ${userIds.join(', ')}`)
const credentials = await db
.select({
@@ -376,6 +381,10 @@ export async function getCredentialsForCredentialSet(
.from(account)
.where(and(inArray(account.userId, userIds), eq(account.providerId, providerId)))
+ logger.info(
+ `Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members`
+ )
+
const results: CredentialSetCredential[] = []
for (const cred of credentials) {
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
index 4da50d0c89..7f92d46fe1 100644
--- a/apps/sim/app/api/credential-sets/[id]/members/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -1,9 +1,10 @@
import { db } from '@sim/db'
-import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema'
+import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-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 { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
@@ -12,6 +13,8 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
+ type: credentialSet.type,
+ providerId: credentialSet.providerId,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
@@ -59,7 +62,58 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
.leftJoin(user, eq(credentialSetMember.userId, user.id))
.where(eq(credentialSetMember.credentialSetId, id))
- return NextResponse.json({ members })
+ // Get credentials for all active members
+ const activeMembers = members.filter((m) => m.status === 'active')
+ const memberUserIds = activeMembers.map((m) => m.userId)
+
+ let credentials: { userId: string; providerId: string; accountId: string }[] = []
+ if (memberUserIds.length > 0) {
+ // If credential set is for a specific provider, filter by that provider
+ if (result.set.type === 'specific' && result.set.providerId) {
+ credentials = await db
+ .select({
+ userId: account.userId,
+ providerId: account.providerId,
+ accountId: account.accountId,
+ })
+ .from(account)
+ .where(
+ and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId))
+ )
+ } else {
+ credentials = await db
+ .select({
+ userId: account.userId,
+ providerId: account.providerId,
+ accountId: account.accountId,
+ })
+ .from(account)
+ .where(inArray(account.userId, memberUserIds))
+ }
+ }
+
+ // Group credentials by userId
+ const credentialsByUser = credentials.reduce(
+ (acc, cred) => {
+ if (!acc[cred.userId]) {
+ acc[cred.userId] = []
+ }
+ acc[cred.userId].push({
+ providerId: cred.providerId,
+ accountId: cred.accountId,
+ })
+ return acc
+ },
+ {} as Record
+ )
+
+ // Attach credentials to members
+ const membersWithCredentials = members.map((m) => ({
+ ...m,
+ credentials: credentialsByUser[m.userId] || [],
+ }))
+
+ return NextResponse.json({ members: membersWithCredentials })
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -106,6 +160,17 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
userId: session.user.id,
})
+ try {
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const syncResult = await syncAllWebhooksForCredentialSet(id, requestId)
+ logger.info('Synced webhooks after member removed', {
+ credentialSetId: id,
+ ...syncResult,
+ })
+ } catch (syncError) {
+ logger.error('Error syncing webhooks after member removed', syncError)
+ }
+
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing member from credential set', error)
diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts
index 1b805b0f7f..9eee99e135 100644
--- a/apps/sim/app/api/credential-sets/[id]/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/route.ts
@@ -20,6 +20,8 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
organizationId: credentialSet.organizationId,
name: credentialSet.name,
description: credentialSet.description,
+ type: credentialSet.type,
+ providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
updatedAt: credentialSet.updatedAt,
diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts
index bd2b0d7afe..ca33bab7af 100644
--- a/apps/sim/app/api/credential-sets/invitations/route.ts
+++ b/apps/sim/app/api/credential-sets/invitations/route.ts
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, gt, or } from 'drizzle-orm'
+import { and, eq, gt, isNull, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -37,7 +37,7 @@ export async function GET() {
and(
or(
eq(credentialSetInvitation.email, session.user.email),
- eq(credentialSetInvitation.email, null)
+ isNull(credentialSetInvitation.email)
),
eq(credentialSetInvitation.status, 'pending'),
gt(credentialSetInvitation.expiresAt, new Date())
diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
index fc9d50817c..a0f8b0e664 100644
--- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts
+++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
@@ -9,6 +9,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
+import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetInviteToken')
@@ -133,12 +134,45 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
})
.where(eq(credentialSetInvitation.id, invitation.id))
+ // Clean up all other pending invitations for the same credential set and email
+ // This prevents duplicate invites from showing up after accepting one
+ if (invitation.email) {
+ await db
+ .update(credentialSetInvitation)
+ .set({
+ status: 'accepted',
+ acceptedAt: now,
+ acceptedByUserId: session.user.id,
+ })
+ .where(
+ and(
+ eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
+ eq(credentialSetInvitation.email, invitation.email),
+ eq(credentialSetInvitation.status, 'pending')
+ )
+ )
+ }
+
logger.info('Accepted credential set invitation', {
invitationId: invitation.id,
credentialSetId: invitation.credentialSetId,
userId: session.user.id,
})
+ try {
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const syncResult = await syncAllWebhooksForCredentialSet(
+ invitation.credentialSetId,
+ requestId
+ )
+ logger.info('Synced webhooks after member joined', {
+ credentialSetId: invitation.credentialSetId,
+ ...syncResult,
+ })
+ } catch (syncError) {
+ logger.error('Error syncing webhooks after member joined', syncError)
+ }
+
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,
diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts
index 1ddeef24a9..4c73f8600e 100644
--- a/apps/sim/app/api/credential-sets/route.ts
+++ b/apps/sim/app/api/credential-sets/route.ts
@@ -8,11 +8,18 @@ import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialSets')
-const createCredentialSetSchema = z.object({
- organizationId: z.string().min(1),
- name: z.string().trim().min(1).max(100),
- description: z.string().max(500).optional(),
-})
+const createCredentialSetSchema = z
+ .object({
+ organizationId: z.string().min(1),
+ name: z.string().trim().min(1).max(100),
+ description: z.string().max(500).optional(),
+ type: z.enum(['all', 'specific']).default('all'),
+ providerId: z.string().min(1).optional(),
+ })
+ .refine((data) => data.type !== 'specific' || data.providerId, {
+ message: 'providerId is required when type is specific',
+ path: ['providerId'],
+ })
export async function GET(req: Request) {
const session = await getSession()
@@ -43,6 +50,8 @@ export async function GET(req: Request) {
id: credentialSet.id,
name: credentialSet.name,
description: credentialSet.description,
+ type: credentialSet.type,
+ providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
updatedAt: credentialSet.updatedAt,
@@ -85,7 +94,8 @@ export async function POST(req: Request) {
try {
const body = await req.json()
- const { organizationId, name, description } = createCredentialSetSchema.parse(body)
+ const { organizationId, name, description, type, providerId } =
+ createCredentialSetSchema.parse(body)
const membership = await db
.select({ id: member.id, role: member.role })
@@ -129,6 +139,8 @@ export async function POST(req: Request) {
organizationId,
name,
description: description || null,
+ type,
+ providerId: type === 'specific' ? providerId : null,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts
index 6f7ffc3a3d..f5674ff36a 100644
--- a/apps/sim/app/api/webhooks/[id]/route.ts
+++ b/apps/sim/app/api/webhooks/[id]/route.ts
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { eq } from 'drizzle-orm'
+import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateInteger } from '@/lib/core/security/input-validation'
@@ -184,16 +184,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
hasFailedCountUpdate: failedCount !== undefined,
})
- // Update the webhook
+ // Merge providerConfig to preserve credential-related fields
+ let finalProviderConfig = webhooks[0].webhook.providerConfig
+ if (providerConfig !== undefined) {
+ const existingConfig = (webhooks[0].webhook.providerConfig as Record) || {}
+ finalProviderConfig = {
+ ...resolvedProviderConfig,
+ credentialId: existingConfig.credentialId,
+ credentialSetId: existingConfig.credentialSetId,
+ userId: existingConfig.userId,
+ historyId: existingConfig.historyId,
+ lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
+ setupCompleted: existingConfig.setupCompleted,
+ externalId: existingConfig.externalId,
+ }
+ }
+
const updatedWebhook = await db
.update(webhook)
.set({
path: path !== undefined ? path : webhooks[0].webhook.path,
provider: provider !== undefined ? provider : webhooks[0].webhook.provider,
- providerConfig:
- providerConfig !== undefined
- ? resolvedProviderConfig
- : webhooks[0].webhook.providerConfig,
+ providerConfig: finalProviderConfig,
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
updatedAt: new Date(),
@@ -276,13 +288,46 @@ export async function DELETE(
}
const foundWebhook = webhookData.webhook
-
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
- await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
- await db.delete(webhook).where(eq(webhook.id, id))
+ const providerConfig = foundWebhook.providerConfig as Record | null
+ const credentialSetId = providerConfig?.credentialSetId as string | undefined
+ const blockId = providerConfig?.blockId as string | undefined
+
+ if (credentialSetId && blockId) {
+ const allCredentialSetWebhooks = await db
+ .select()
+ .from(webhook)
+ .where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
+
+ const webhooksToDelete = allCredentialSetWebhooks.filter((w) => {
+ const config = w.providerConfig as Record | null
+ return config?.credentialSetId === credentialSetId
+ })
+
+ for (const w of webhooksToDelete) {
+ await cleanupExternalWebhook(w, webhookData.workflow, requestId)
+ }
+
+ const idsToDelete = webhooksToDelete.map((w) => w.id)
+ for (const wId of idsToDelete) {
+ await db.delete(webhook).where(eq(webhook.id, wId))
+ }
+
+ logger.info(
+ `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`,
+ {
+ credentialSetId,
+ blockId,
+ deletedIds: idsToDelete,
+ }
+ )
+ } else {
+ await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
+ await db.delete(webhook).where(eq(webhook.id, id))
+ logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
+ }
- logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting webhook`, {
diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts
index 62ad40db06..5456eb631b 100644
--- a/apps/sim/app/api/webhooks/route.ts
+++ b/apps/sim/app/api/webhooks/route.ts
@@ -262,6 +262,157 @@ export async function POST(request: NextRequest) {
workflowRecord.workspaceId || undefined
)
+ // --- Credential Set Handling ---
+ // For credential sets, we fan out to create one webhook per credential at save time.
+ // This applies to all OAuth-based triggers, not just polling ones.
+ // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields
+ const rawCredentialId = (resolvedProviderConfig?.credentialId ||
+ resolvedProviderConfig?.triggerCredentials) as string | undefined
+ const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
+
+ if (directCredentialSetId || rawCredentialId) {
+ const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants')
+
+ const credentialSetId =
+ directCredentialSetId ||
+ (rawCredentialId && isCredentialSetValue(rawCredentialId)
+ ? extractCredentialSetId(rawCredentialId)
+ : null)
+
+ if (credentialSetId) {
+ logger.info(
+ `[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}`
+ )
+
+ const { getProviderIdFromServiceId } = await import('@/lib/oauth')
+ const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } =
+ await import('@/lib/webhooks/utils.server')
+
+ // Map provider to OAuth provider ID
+ const oauthProviderId = getProviderIdFromServiceId(provider)
+
+ const {
+ credentialId: _cId,
+ triggerCredentials: _tCred,
+ credentialSetId: _csId,
+ ...baseProviderConfig
+ } = resolvedProviderConfig
+
+ try {
+ const syncResult = await syncWebhooksForCredentialSet({
+ workflowId,
+ blockId,
+ provider,
+ basePath: finalPath,
+ credentialSetId,
+ oauthProviderId,
+ providerConfig: baseProviderConfig,
+ requestId,
+ })
+
+ if (syncResult.webhooks.length === 0) {
+ logger.error(
+ `[${requestId}] No webhooks created for credential set - no valid credentials found`
+ )
+ return NextResponse.json(
+ {
+ error: `No valid credentials found in credential set for ${provider}`,
+ details: 'Please ensure team members have connected their accounts',
+ },
+ { status: 400 }
+ )
+ }
+
+ // Configure each new webhook (for providers that need configuration)
+ const pollingProviders = ['gmail', 'outlook']
+ const needsConfiguration = pollingProviders.includes(provider)
+
+ if (needsConfiguration) {
+ const configureFunc =
+ provider === 'gmail' ? configureGmailPolling : configureOutlookPolling
+ const configureErrors: string[] = []
+
+ for (const wh of syncResult.webhooks) {
+ if (wh.isNew) {
+ // Fetch the webhook data for configuration
+ const webhookRows = await db
+ .select()
+ .from(webhook)
+ .where(eq(webhook.id, wh.id))
+ .limit(1)
+
+ if (webhookRows.length > 0) {
+ const success = await configureFunc(webhookRows[0], requestId)
+ if (!success) {
+ configureErrors.push(
+ `Failed to configure webhook for credential ${wh.credentialId}`
+ )
+ logger.warn(
+ `[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}`
+ )
+ }
+ }
+ }
+ }
+
+ if (
+ configureErrors.length > 0 &&
+ configureErrors.length === syncResult.webhooks.length
+ ) {
+ // All configurations failed - roll back
+ logger.error(`[${requestId}] All webhook configurations failed, rolling back`)
+ for (const wh of syncResult.webhooks) {
+ await db.delete(webhook).where(eq(webhook.id, wh.id))
+ }
+ return NextResponse.json(
+ {
+ error: `Failed to configure ${provider} polling`,
+ details: 'Please check account permissions and try again',
+ },
+ { status: 500 }
+ )
+ }
+ }
+
+ logger.info(
+ `[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}`
+ )
+
+ // Return the first webhook as the "primary" for the UI
+ // The UI will query by credentialSetId to get all of them
+ const primaryWebhookRows = await db
+ .select()
+ .from(webhook)
+ .where(eq(webhook.id, syncResult.webhooks[0].id))
+ .limit(1)
+
+ return NextResponse.json(
+ {
+ webhook: primaryWebhookRows[0],
+ credentialSetInfo: {
+ credentialSetId,
+ totalWebhooks: syncResult.webhooks.length,
+ created: syncResult.created,
+ updated: syncResult.updated,
+ deleted: syncResult.deleted,
+ },
+ },
+ { status: syncResult.created > 0 ? 201 : 200 }
+ )
+ } catch (err) {
+ logger.error(`[${requestId}] Error syncing webhooks for credential set`, err)
+ return NextResponse.json(
+ {
+ error: `Failed to configure ${provider} webhook`,
+ details: err instanceof Error ? err.message : 'Unknown error',
+ },
+ { status: 500 }
+ )
+ }
+ }
+ }
+ // --- End Credential Set Handling ---
+
// Create external subscriptions before saving to DB to prevent orphaned records
let externalSubscriptionId: string | undefined
let externalSubscriptionCreated = false
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 9cdff87dc5..a7bafc236a 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
- findWebhookAndWorkflow,
+ findAllWebhooksForPath,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
@@ -86,109 +86,151 @@ export async function POST(
return challengeResponse
}
- const findResult = await findWebhookAndWorkflow({ requestId, path })
+ // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path)
+ const webhooksForPath = await findAllWebhooksForPath({ requestId, path })
- if (!findResult) {
+ if (webhooksForPath.length === 0) {
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
-
return new NextResponse('Not Found', { status: 404 })
}
- const { webhook: foundWebhook, workflow: foundWorkflow } = findResult
+ // Process each webhook
+ // For credential sets with shared paths, each webhook represents a different credential
+ const responses: NextResponse[] = []
+
+ for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
+ // Log HubSpot webhook details for debugging
+ if (foundWebhook.provider === 'hubspot') {
+ const events = Array.isArray(body) ? body : [body]
+ const firstEvent = events[0]
+
+ logger.info(`[${requestId}] HubSpot webhook received`, {
+ path,
+ subscriptionType: firstEvent?.subscriptionType,
+ objectId: firstEvent?.objectId,
+ portalId: firstEvent?.portalId,
+ webhookId: foundWebhook.id,
+ workflowId: foundWorkflow.id,
+ triggerId: foundWebhook.providerConfig?.triggerId,
+ eventCount: events.length,
+ })
+ }
- // Log HubSpot webhook details for debugging
- if (foundWebhook.provider === 'hubspot') {
- const events = Array.isArray(body) ? body : [body]
- const firstEvent = events[0]
+ const authError = await verifyProviderAuth(
+ foundWebhook,
+ foundWorkflow,
+ request,
+ rawBody,
+ requestId
+ )
+ if (authError) {
+ // For multi-webhook, log and continue to next webhook
+ if (webhooksForPath.length > 1) {
+ logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
+ continue
+ }
+ return authError
+ }
- logger.info(`[${requestId}] HubSpot webhook received`, {
- path,
- subscriptionType: firstEvent?.subscriptionType,
- objectId: firstEvent?.objectId,
- portalId: firstEvent?.portalId,
- webhookId: foundWebhook.id,
- workflowId: foundWorkflow.id,
- triggerId: foundWebhook.providerConfig?.triggerId,
- eventCount: events.length,
- })
- }
+ const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
+ if (reachabilityResponse) {
+ // Reachability test should return immediately for the first webhook
+ return reachabilityResponse
+ }
- const authError = await verifyProviderAuth(
- foundWebhook,
- foundWorkflow,
- request,
- rawBody,
- requestId
- )
- if (authError) {
- return authError
- }
+ let preprocessError: NextResponse | null = null
+ try {
+ preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
+ if (preprocessError) {
+ if (webhooksForPath.length > 1) {
+ logger.warn(
+ `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
+ )
+ continue
+ }
+ return preprocessError
+ }
+ } catch (error) {
+ logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ webhookId: foundWebhook.id,
+ workflowId: foundWorkflow.id,
+ })
- const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
- if (reachabilityResponse) {
- return reachabilityResponse
- }
+ if (webhooksForPath.length > 1) {
+ continue
+ }
- let preprocessError: NextResponse | null = null
- try {
- preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
- if (preprocessError) {
- return preprocessError
- }
- } catch (error) {
- logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
- error: error instanceof Error ? error.message : String(error),
- stack: error instanceof Error ? error.stack : undefined,
- webhookId: foundWebhook.id,
- workflowId: foundWorkflow.id,
- })
+ if (foundWebhook.provider === 'microsoft-teams') {
+ return NextResponse.json(
+ {
+ type: 'message',
+ text: 'An unexpected error occurred during preprocessing',
+ },
+ { status: 500 }
+ )
+ }
- if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
- {
- type: 'message',
- text: 'An unexpected error occurred during preprocessing',
- },
+ { error: 'An unexpected error occurred during preprocessing' },
{ status: 500 }
)
}
- return NextResponse.json(
- { error: 'An unexpected error occurred during preprocessing' },
- { status: 500 }
- )
- }
-
- if (foundWebhook.blockId) {
- const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
- if (!blockExists) {
- logger.info(
- `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
- )
- return new NextResponse('Trigger block not found in deployment', { status: 404 })
+ if (foundWebhook.blockId) {
+ const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
+ if (!blockExists) {
+ logger.info(
+ `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
+ )
+ if (webhooksForPath.length > 1) {
+ continue
+ }
+ return new NextResponse('Trigger block not found in deployment', { status: 404 })
+ }
}
- }
- if (foundWebhook.provider === 'stripe') {
- const providerConfig = (foundWebhook.providerConfig as Record) || {}
- const eventTypes = providerConfig.eventTypes
+ if (foundWebhook.provider === 'stripe') {
+ const providerConfig = (foundWebhook.providerConfig as Record) || {}
+ const eventTypes = providerConfig.eventTypes
- if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
- const eventType = body?.type
+ if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
+ const eventType = body?.type
- if (eventType && !eventTypes.includes(eventType)) {
- logger.info(
- `[${requestId}] Stripe event type '${eventType}' not in allowed list, skipping execution`
- )
- return new NextResponse('Event type filtered', { status: 200 })
+ if (eventType && !eventTypes.includes(eventType)) {
+ logger.info(
+ `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${foundWebhook.id}, skipping`
+ )
+ continue
+ }
}
}
+
+ const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
+ requestId,
+ path,
+ testMode: false,
+ executionTarget: 'deployed',
+ })
+ responses.push(response)
}
- return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
- requestId,
- path,
- testMode: false,
- executionTarget: 'deployed',
+ // Return the last successful response, or a combined response for multiple webhooks
+ if (responses.length === 0) {
+ return new NextResponse('No webhooks processed successfully', { status: 500 })
+ }
+
+ if (responses.length === 1) {
+ return responses[0]
+ }
+
+ // For multiple webhooks, return success if at least one succeeded
+ logger.info(
+ `[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)`
+ )
+ return NextResponse.json({
+ success: true,
+ webhooksProcessed: responses.length,
})
}
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
index 1eb0e6382e..d363f30013 100644
--- a/apps/sim/app/credential-account/[token]/page.tsx
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -104,7 +104,7 @@ export default function CredentialAccountInvitePage() {
You've successfully joined {invitation?.credentialSetName}. Connect your OAuth
credentials in Settings → Integrations.
- router.push('/w')}>
+ router.push('/workspace')}>
Go to Dashboard
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index 7533b838dc..ca82ee94a9 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -16,6 +16,7 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
+import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
@@ -25,8 +26,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
-const CREDENTIAL_SET_PREFIX = 'credentialSet:'
-
interface CredentialSelectorProps {
blockId: string
subBlock: SubBlockConfig
@@ -72,10 +71,10 @@ export function CredentialSelector({
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
- const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET_PREFIX)
+ const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX)
const selectedId = isCredentialSetSelected ? '' : rawSelectedId
const selectedCredentialSetId = isCredentialSetSelected
- ? rawSelectedId.slice(CREDENTIAL_SET_PREFIX.length)
+ ? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length)
: ''
const effectiveProviderId = useMemo(
@@ -181,7 +180,7 @@ export function CredentialSelector({
const handleCredentialSetSelect = useCallback(
(credentialSetId: string) => {
if (isPreview) return
- setStoreValue(`${CREDENTIAL_SET_PREFIX}${credentialSetId}`)
+ setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`)
setIsEditing(false)
},
[isPreview, setStoreValue]
@@ -216,14 +215,18 @@ export function CredentialSelector({
}, [])
const { comboboxOptions, comboboxGroups } = useMemo(() => {
- if (canUseCredentialSets && credentialSets.length > 0) {
+ const filteredCredentialSets = credentialSets.filter(
+ (cs) => cs.type === 'all' || cs.providerId === effectiveProviderId
+ )
+
+ if (canUseCredentialSets && filteredCredentialSets.length > 0) {
const groups = []
groups.push({
section: 'Credential Sets',
- items: credentialSets.map((cs) => ({
- label: `${cs.name} (${cs.memberCount} members)`,
- value: `${CREDENTIAL_SET_PREFIX}${cs.id}`,
+ items: filteredCredentialSets.map((cs) => ({
+ label: cs.name,
+ value: `${CREDENTIAL_SET.PREFIX}${cs.id}`,
})),
})
@@ -237,7 +240,7 @@ export function CredentialSelector({
section: 'My Credentials',
items: credentialItems,
})
- } else if (!isCredentialSetSelected) {
+ } else {
groups.push({
section: 'My Credentials',
items: [
@@ -268,10 +271,10 @@ export function CredentialSelector({
}, [
credentials,
provider,
+ effectiveProviderId,
getProviderName,
canUseCredentialSets,
credentialSets,
- isCredentialSetSelected,
])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
@@ -313,8 +316,8 @@ export function CredentialSelector({
return
}
- if (value.startsWith(CREDENTIAL_SET_PREFIX)) {
- const credentialSetId = value.slice(CREDENTIAL_SET_PREFIX.length)
+ if (value.startsWith(CREDENTIAL_SET.PREFIX)) {
+ const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
if (matchedSet) {
setInputValue(matchedSet.name)
@@ -353,7 +356,7 @@ export function CredentialSelector({
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
- className={selectedId ? 'pl-[28px]' : ''}
+ className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
/>
{needsUpdate && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 69ff2b2dd7..8f38c485c6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -1,10 +1,14 @@
'use client'
-import { useCallback, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { Check, Copy, Loader2, Plus } from 'lucide-react'
+import { ArrowLeft, Check, Copy, Loader2, Plus, Trash2, User } from 'lucide-react'
import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
Button,
+ Combobox,
Input,
Label,
Modal,
@@ -16,14 +20,19 @@ import {
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
+import { OAUTH_PROVIDERS } from '@/lib/oauth'
import { getUserRole } from '@/lib/workspaces/organization'
import {
+ type CredentialSet,
+ type CredentialSetType,
useAcceptCredentialSetInvitation,
useCreateCredentialSet,
useCreateCredentialSetInvitation,
useCredentialSetInvitations,
+ useCredentialSetMembers,
useCredentialSetMemberships,
useCredentialSets,
+ useRemoveCredentialSetMember,
} from '@/hooks/queries/credential-sets'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
@@ -66,10 +75,60 @@ export function CredentialSets() {
const [showCreateModal, setShowCreateModal] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const [selectedSetId, setSelectedSetId] = useState(null)
+ const [viewingSet, setViewingSet] = useState(null)
const [newSetName, setNewSetName] = useState('')
const [newSetDescription, setNewSetDescription] = useState('')
+ const [newSetType, setNewSetType] = useState('all')
+ const [newSetProviderId, setNewSetProviderId] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [copiedLink, setCopiedLink] = useState(false)
+ const [generatedInviteUrl, setGeneratedInviteUrl] = useState(null)
+
+ const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id)
+ const removeMember = useRemoveCredentialSetMember()
+
+ const providerOptions = useMemo(() => {
+ const options: { label: string; value: string }[] = []
+ for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) {
+ if (provider.services) {
+ for (const [, service] of Object.entries(provider.services)) {
+ options.push({
+ label: service.name,
+ value: service.providerId,
+ })
+ }
+ }
+ }
+ return options.sort((a, b) => a.label.localeCompare(b.label))
+ }, [])
+
+ const getProviderName = useCallback((providerId: string) => {
+ for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) {
+ if (provider.services) {
+ for (const [, service] of Object.entries(provider.services)) {
+ if (service.providerId === providerId) {
+ return service.name
+ }
+ }
+ }
+ }
+ return providerId
+ }, [])
+
+ const handleRemoveMember = useCallback(
+ async (memberId: string) => {
+ if (!viewingSet) return
+ try {
+ await removeMember.mutateAsync({
+ credentialSetId: viewingSet.id,
+ memberId,
+ })
+ } catch (error) {
+ logger.error('Failed to remove member', error)
+ }
+ },
+ [viewingSet, removeMember]
+ )
const handleAcceptInvitation = useCallback(
async (token: string) => {
@@ -84,19 +143,31 @@ export function CredentialSets() {
const handleCreateCredentialSet = useCallback(async () => {
if (!newSetName.trim() || !activeOrganization?.id) return
+ if (newSetType === 'specific' && !newSetProviderId) return
try {
await createCredentialSet.mutateAsync({
organizationId: activeOrganization.id,
name: newSetName.trim(),
description: newSetDescription.trim() || undefined,
+ type: newSetType,
+ providerId: newSetType === 'specific' ? newSetProviderId : undefined,
})
setShowCreateModal(false)
setNewSetName('')
setNewSetDescription('')
+ setNewSetType('all')
+ setNewSetProviderId('')
} catch (error) {
logger.error('Failed to create credential set', error)
}
- }, [newSetName, newSetDescription, activeOrganization?.id, createCredentialSet])
+ }, [
+ newSetName,
+ newSetDescription,
+ newSetType,
+ newSetProviderId,
+ activeOrganization?.id,
+ createCredentialSet,
+ ])
const handleCreateInvite = useCallback(async () => {
if (!selectedSetId) return
@@ -107,18 +178,40 @@ export function CredentialSets() {
})
const inviteUrl = result.invitation?.inviteUrl
if (inviteUrl) {
- await navigator.clipboard.writeText(inviteUrl)
- setCopiedLink(true)
- setTimeout(() => setCopiedLink(false), 2000)
+ setGeneratedInviteUrl(inviteUrl)
+ try {
+ await navigator.clipboard.writeText(inviteUrl)
+ setCopiedLink(true)
+ setTimeout(() => setCopiedLink(false), 2000)
+ } catch {
+ // Clipboard failed, URL is shown in modal for manual copy
+ }
}
- setShowInviteModal(false)
setInviteEmail('')
- setSelectedSetId(null)
} catch (error) {
logger.error('Failed to create invitation', error)
}
}, [selectedSetId, inviteEmail, createInvitation])
+ const handleCopyInviteUrl = useCallback(async () => {
+ if (!generatedInviteUrl) return
+ try {
+ await navigator.clipboard.writeText(generatedInviteUrl)
+ setCopiedLink(true)
+ setTimeout(() => setCopiedLink(false), 2000)
+ } catch {
+ // Fallback: select the input text
+ }
+ }, [generatedInviteUrl])
+
+ const handleCloseInviteModal = useCallback(() => {
+ setShowInviteModal(false)
+ setInviteEmail('')
+ setSelectedSetId(null)
+ setGeneratedInviteUrl(null)
+ setCopiedLink(false)
+ }, [])
+
if (membershipsLoading || invitationsLoading) {
return
}
@@ -127,6 +220,207 @@ export function CredentialSets() {
const hasNoContent =
invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0
+ // Detail view for a credential set
+ if (viewingSet) {
+ const activeMembers = members.filter((m) => m.status === 'active')
+ const pendingMembers = members.filter((m) => m.status === 'pending')
+
+ return (
+
+
+
setViewingSet(null)} className='p-[4px]'>
+
+
+
+
+ {viewingSet.name}
+
+
+ {viewingSet.type === 'all'
+ ? 'All Integrations'
+ : `${getProviderName(viewingSet.providerId || '')} Only`}
+
+
+
+
+ {membersLoading ? (
+
+
+
+
+ ) : (
+ <>
+ {activeMembers.length > 0 && (
+
+
+ Active Members ({activeMembers.length})
+
+ {activeMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
+ {member.userName || 'Unknown'}
+
+
+ {member.userEmail}
+
+
+
+
handleRemoveMember(member.id)}
+ disabled={removeMember.isPending}
+ className='p-[6px] text-[var(--text-muted)] hover:text-[var(--text-error)]'
+ >
+
+
+
+ ))}
+
+ )}
+
+ {pendingMembers.length > 0 && (
+
+
+ Pending ({pendingMembers.length})
+
+ {pendingMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
+ {member.userName || 'Unknown'}
+
+
+ {member.userEmail}
+
+
+
+
Pending
+
+ ))}
+
+ )}
+
+ {members.length === 0 && (
+
+
No members yet
+
+ Invite people to join this credential set
+
+
+ )}
+ >
+ )}
+
+
+
{
+ setSelectedSetId(viewingSet.id)
+ setShowInviteModal(true)
+ }}
+ >
+
+ Invite Member
+
+
+
+
+
+ Invite to Credential Set
+
+
+ {generatedInviteUrl ? (
+ <>
+
+
Invite Link
+
+ (e.target as HTMLInputElement).select()}
+ />
+
+ {copiedLink ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Share this link with the person you want to invite.
+
+ >
+ ) : (
+ <>
+
+ Email (optional)
+ setInviteEmail(e.target.value)}
+ placeholder='Leave empty for a shareable link'
+ />
+
+
+ Generate an invite link to share.
+
+ >
+ )}
+
+
+
+ {generatedInviteUrl ? (
+
+ Done
+
+ ) : (
+ <>
+
+ Cancel
+
+
+ {createInvitation.isPending ? (
+
+ ) : (
+ 'Generate Link'
+ )}
+
+ >
+ )}
+
+
+
+
+ )
+ }
+
return (
{hasNoContent && !canManageCredentialSets && (
@@ -157,7 +451,7 @@ export function CredentialSets() {
handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
@@ -197,7 +491,7 @@ export function CredentialSets() {
Manage
-
setShowCreateModal(true)}>
+ setShowCreateModal(true)}>
Create
@@ -218,7 +512,8 @@ export function CredentialSets() {
ownedSets.map((set) => (
setViewingSet(set)}
>
@@ -226,11 +521,15 @@ export function CredentialSets() {
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+ {set.type === 'specific' && set.providerId && (
+ <> · {getProviderName(set.providerId)}>
+ )}
{
+ variant='tertiary'
+ onClick={(e) => {
+ e.stopPropagation()
setSelectedSetId(set.id)
setShowInviteModal(true)
}}
@@ -264,16 +563,63 @@ export function CredentialSets() {
placeholder='e.g., Credentials for marketing automations'
/>
+
+
Type
+
+ {
+ setNewSetType('all')
+ setNewSetProviderId('')
+ }}
+ className='flex-1'
+ >
+ All Integrations
+
+ setNewSetType('specific')}
+ className='flex-1'
+ >
+ Specific Integration
+
+
+
+ {newSetType === 'all'
+ ? 'Members share all their connected credentials'
+ : 'Members share only credentials for a specific integration'}
+
+
+ {newSetType === 'specific' && (
+
+ Integration
+ p.value === newSetProviderId)?.label ||
+ newSetProviderId
+ }
+ selectedValue={newSetProviderId}
+ onChange={(value) => setNewSetProviderId(value)}
+ placeholder='Select an integration'
+ filterOptions
+ />
+
+ )}
- setShowCreateModal(false)}>
+ setShowCreateModal(false)}>
Cancel
{createCredentialSet.isPending ? (
@@ -285,48 +631,75 @@ export function CredentialSets() {
-
+
Invite to Credential Set
-
- Email (optional)
- setInviteEmail(e.target.value)}
- placeholder='Leave empty for a shareable link'
- />
-
-
- An invite link will be copied to your clipboard.
-
-
-
-
- setShowInviteModal(false)}>
- Cancel
-
-
- {createInvitation.isPending ? (
-
- ) : copiedLink ? (
+ {generatedInviteUrl ? (
<>
-
- Copied!
+
+
Invite Link
+
+ (e.target as HTMLInputElement).select()}
+ />
+
+ {copiedLink ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Share this link with the person you want to invite.
+
>
) : (
<>
-
- Copy Invite Link
+
+ Email (optional)
+ setInviteEmail(e.target.value)}
+ placeholder='Leave empty for a shareable link'
+ />
+
+
+ Generate an invite link to share.
+
>
)}
-
+
+
+
+ {generatedInviteUrl ? (
+
+ Done
+
+ ) : (
+ <>
+
+ Cancel
+
+
+ {createInvitation.isPending ? (
+
+ ) : (
+ 'Generate Link'
+ )}
+
+ >
+ )}
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index d10fcc0283..a8f1bd3e36 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -252,6 +252,10 @@ async function executeWebhookJobInternal(
},
}
+ if (payload.credentialId && typeof airtableInput === 'object') {
+ airtableInput.currentUser = payload.credentialId
+ }
+
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
@@ -511,7 +515,12 @@ async function executeWebhookJobInternal(
},
}
- const snapshot = new ExecutionSnapshot(metadata, workflow, input || {}, workflowVariables, [])
+ const triggerInput = input || {}
+ if (payload.credentialId && typeof triggerInput === 'object') {
+ triggerInput.currentUser = payload.credentialId
+ }
+
+ const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
const executionResult = await executeWorkflowCore({
snapshot,
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index 973db5b75e..a1d6df77d6 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -112,7 +112,6 @@ export const REFERENCE = {
LOOP: 'loop',
PARALLEL: 'parallel',
VARIABLE: 'variable',
- CREDENTIAL_SET: 'credentialSet',
},
} as const
@@ -120,7 +119,6 @@ export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.LOOP,
REFERENCE.PREFIX.PARALLEL,
REFERENCE.PREFIX.VARIABLE,
- REFERENCE.PREFIX.CREDENTIAL_SET,
] as const
export const LOOP_REFERENCE = {
@@ -182,6 +180,18 @@ export const MCP = {
TOOL_PREFIX: 'mcp-',
} as const
+export const CREDENTIAL_SET = {
+ PREFIX: 'credentialSet:',
+} as const
+
+export function isCredentialSetValue(value: string | null | undefined): boolean {
+ return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
+}
+
+export function extractCredentialSetId(value: string): string {
+ return value.slice(CREDENTIAL_SET.PREFIX.length)
+}
+
export const MEMORY = {
DEFAULT_SLIDING_WINDOW_SIZE: 10,
DEFAULT_SLIDING_WINDOW_TOKENS: 4000,
diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts
index f4140b65f2..980708931b 100644
--- a/apps/sim/executor/variables/resolver.ts
+++ b/apps/sim/executor/variables/resolver.ts
@@ -4,7 +4,6 @@ import type { ExecutionState, LoopScope } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation'
import { BlockResolver } from '@/executor/variables/resolvers/block'
-import { CredentialSetResolver } from '@/executor/variables/resolvers/credential-set'
import { EnvResolver } from '@/executor/variables/resolvers/env'
import { LoopResolver } from '@/executor/variables/resolvers/loop'
import { ParallelResolver } from '@/executor/variables/resolvers/parallel'
@@ -28,7 +27,6 @@ export class VariableResolver {
new LoopResolver(workflow),
new ParallelResolver(workflow),
new WorkflowResolver(workflowVariables),
- new CredentialSetResolver(),
new EnvResolver(),
this.blockResolver,
]
diff --git a/apps/sim/executor/variables/resolvers/credential-set.ts b/apps/sim/executor/variables/resolvers/credential-set.ts
deleted file mode 100644
index 00560b7749..0000000000
--- a/apps/sim/executor/variables/resolvers/credential-set.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { parseReferencePath, REFERENCE } from '@/executor/constants'
-import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference'
-
-const logger = createLogger('CredentialSetResolver')
-
-export const CREDENTIAL_SET_USER_PREFIX = 'credentialSetUser:'
-
-export class CredentialSetResolver implements Resolver {
- canResolve(reference: string): boolean {
- const parts = parseReferencePath(reference)
- if (parts.length < 1) return false
-
- const type = parts[0]
- return type === REFERENCE.PREFIX.CREDENTIAL_SET
- }
-
- resolve(reference: string, context: ResolutionContext): any {
- const parts = parseReferencePath(reference)
- if (parts.length < 2) {
- logger.warn('Invalid credential set reference - missing property', { reference })
- return undefined
- }
-
- const [_, property] = parts
-
- if (property === 'currentUser') {
- const credentialAccountUserId = context.executionContext.metadata?.credentialAccountUserId
- if (!credentialAccountUserId) {
- logger.warn(
- 'credentialSet.currentUser referenced but no credentialAccountUserId in execution context',
- { reference }
- )
- return undefined
- }
- return `${CREDENTIAL_SET_USER_PREFIX}${credentialAccountUserId}`
- }
-
- logger.warn('Unknown credential set property', { property, reference })
- return undefined
- }
-}
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index cc61d2bac3..9855fc6b4f 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -1,10 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'
+export type CredentialSetType = 'all' | 'specific'
+
export interface CredentialSet {
id: string
name: string
description: string | null
+ type: CredentialSetType
+ providerId: string | null
createdBy: string
createdAt: string
updatedAt: string
@@ -118,11 +122,19 @@ export function useAcceptCredentialSetInvitation() {
})
}
+export interface CreateCredentialSetData {
+ organizationId: string
+ name: string
+ description?: string
+ type?: CredentialSetType
+ providerId?: string
+}
+
export function useCreateCredentialSet() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async (data: { organizationId: string; name: string; description?: string }) => {
+ mutationFn: async (data: CreateCredentialSetData) => {
const response = await fetch('/api/credential-sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -161,3 +173,57 @@ export function useCreateCredentialSetInvitation() {
},
})
}
+
+export interface CredentialSetMember {
+ id: string
+ userId: string
+ status: string
+ joinedAt: string | null
+ createdAt: string
+ userName: string | null
+ userEmail: string | null
+ userImage: string | null
+ credentials: { providerId: string; accountId: string }[]
+}
+
+interface MembersResponse {
+ members?: CredentialSetMember[]
+}
+
+export function useCredentialSetMembers(credentialSetId?: string) {
+ return useQuery({
+ queryKey: [...credentialSetKeys.detail(credentialSetId), 'members'],
+ queryFn: async () => {
+ const data = await fetchJson(
+ `/api/credential-sets/${credentialSetId}/members`
+ )
+ return data.members ?? []
+ },
+ enabled: Boolean(credentialSetId),
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useRemoveCredentialSetMember() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; memberId: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/members?memberId=${data.memberId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to remove member')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'members'],
+ })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.all })
+ },
+ })
+}
diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts
index a10382989c..e442658ee3 100644
--- a/apps/sim/hooks/use-webhook-management.ts
+++ b/apps/sim/hooks/use-webhook-management.ts
@@ -171,7 +171,22 @@ export function useWebhookManagement({
if (webhook.providerConfig) {
const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId, webhook)
- useSubBlockStore.getState().setValue(blockId, 'triggerConfig', webhook.providerConfig)
+ // Filter out runtime/system fields from providerConfig before storing as triggerConfig
+ // These fields are managed by the system and should not be included in change detection
+ const {
+ credentialId: _credId,
+ credentialSetId: _credSetId,
+ userId: _userId,
+ historyId: _historyId,
+ lastCheckedTimestamp: _lastChecked,
+ setupCompleted: _setupCompleted,
+ externalId: _externalId,
+ triggerId: _triggerId,
+ blockId: _blockId,
+ ...userConfigurableFields
+ } = webhook.providerConfig as Record
+
+ useSubBlockStore.getState().setValue(blockId, 'triggerConfig', userConfigurableFields)
if (effectiveTriggerId) {
populateTriggerFieldsFromConfig(blockId, webhook.providerConfig, effectiveTriggerId)
diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts
index cc62c30afc..d237dae75f 100644
--- a/apps/sim/lib/webhooks/gmail-polling-service.ts
+++ b/apps/sim/lib/webhooks/gmail-polling-service.ts
@@ -141,17 +141,19 @@ export async function pollGmailWebhooks() {
try {
const metadata = webhookData.providerConfig as any
+ // Each webhook now has its own credentialId (credential sets are fanned out at save time)
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
if (!credentialId && !userId) {
- logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
+ logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
await markWebhookFailed(webhookId)
failureCount++
return
}
let accessToken: string | null = null
+
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
@@ -165,13 +167,12 @@ export async function pollGmailWebhooks() {
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
+ // Legacy fallback for webhooks without credentialId
accessToken = await getOAuthToken(userId, 'google-email')
}
if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)`
- )
+ logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`)
await markWebhookFailed(webhookId)
failureCount++
return
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index d1ef7b1c6a..cc4922080c 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -1,10 +1,12 @@
import { db, webhook, workflow } from '@sim/db'
+import { credentialSet, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
-import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
+import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
+import { isProd, isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
import {
@@ -13,7 +15,6 @@ import {
validateMicrosoftTeamsSignature,
verifyProviderWebhook,
} from '@/lib/webhooks/utils.server'
-import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
@@ -41,6 +42,48 @@ function getExternalUrl(request: NextRequest): string {
return request.url
}
+async function verifyCredentialSetBilling(credentialSetId: string): Promise<{
+ valid: boolean
+ error?: string
+}> {
+ if (!isProd) {
+ return { valid: true }
+ }
+
+ const [set] = await db
+ .select({ organizationId: credentialSet.organizationId })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (!set) {
+ return { valid: false, error: 'Credential set not found' }
+ }
+
+ const [orgSub] = await db
+ .select()
+ .from(subscription)
+ .where(and(eq(subscription.referenceId, set.organizationId), eq(subscription.status, 'active')))
+ .limit(1)
+
+ if (!orgSub) {
+ return {
+ valid: false,
+ error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.',
+ }
+ }
+
+ const hasTeamPlan = checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)
+ if (!hasTeamPlan) {
+ return {
+ valid: false,
+ error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.',
+ }
+ }
+
+ return { valid: true }
+}
+
export async function parseWebhookBody(
request: NextRequest,
requestId: string
@@ -194,6 +237,37 @@ export async function findWebhookAndWorkflow(
return null
}
+/**
+ * Find ALL webhooks matching a path.
+ * Used for credential sets where multiple webhooks share the same path.
+ */
+export async function findAllWebhooksForPath(
+ options: WebhookProcessorOptions
+): Promise> {
+ if (!options.path) {
+ return []
+ }
+
+ const results = await db
+ .select({
+ webhook: webhook,
+ workflow: workflow,
+ })
+ .from(webhook)
+ .innerJoin(workflow, eq(webhook.workflowId, workflow.id))
+ .where(and(eq(webhook.path, options.path), eq(webhook.isActive, true)))
+
+ if (results.length === 0) {
+ logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`)
+ } else if (results.length > 1) {
+ logger.info(
+ `[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)`
+ )
+ }
+
+ return results
+}
+
/**
* Resolve {{VARIABLE}} references in a string value
* @param value - String that may contain {{VARIABLE}} references
@@ -748,12 +822,24 @@ export async function queueWebhookExecution(
}
}
- // Extract credentialId or credentialSetId from webhook config
+ // Extract credentialId from webhook config
+ // Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
const providerConfig = (foundWebhook.providerConfig as Record) || {}
const credentialId = providerConfig.credentialId as string | undefined
const credentialSetId = providerConfig.credentialSetId as string | undefined
- const basePayload = {
+ // Verify billing for credential sets
+ if (credentialSetId) {
+ const billingCheck = await verifyCredentialSetBilling(credentialSetId)
+ if (!billingCheck.valid) {
+ logger.warn(
+ `[${options.requestId}] Credential set billing check failed: ${billingCheck.error}`
+ )
+ return NextResponse.json({ error: billingCheck.error }, { status: 403 })
+ }
+ }
+
+ const payload = {
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
userId: foundWorkflow.userId,
@@ -764,72 +850,25 @@ export async function queueWebhookExecution(
blockId: foundWebhook.blockId,
testMode: options.testMode,
executionTarget: options.executionTarget,
+ ...(credentialId ? { credentialId } : {}),
}
- if (credentialSetId) {
- const credentials = await getCredentialsForCredentialSet(
- credentialSetId,
- foundWebhook.provider
- )
-
- if (credentials.length === 0) {
- logger.warn(
- `[${options.requestId}] No valid credentials found for credential set ${credentialSetId}, provider ${foundWebhook.provider}`
- )
- return NextResponse.json(
- { error: 'No valid credentials in credential set' },
- { status: 400 }
- )
- }
-
+ if (isTriggerDevEnabled) {
+ const handle = await tasks.trigger('webhook-execution', payload)
logger.info(
- `[${options.requestId}] Fan-out: Queuing ${credentials.length} executions for credential set ${credentialSetId}`
+ `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
+ handle.id
+ } for ${foundWebhook.provider} webhook`
)
-
- for (const cred of credentials) {
- const fanOutPayload = {
- ...basePayload,
- credentialId: cred.credentialId,
- credentialAccountUserId: cred.userId,
- }
-
- if (isTriggerDevEnabled) {
- const handle = await tasks.trigger('webhook-execution', fanOutPayload)
- logger.info(
- `[${options.requestId}] Queued fan-out execution ${handle.id} for user ${cred.userId}`
- )
- } else {
- void executeWebhookJob(fanOutPayload).catch((error) => {
- logger.error(
- `[${options.requestId}] Direct fan-out execution failed for user ${cred.userId}`,
- error
- )
- })
- }
- }
} else {
- const payload = {
- ...basePayload,
- ...(credentialId ? { credentialId } : {}),
- }
-
- if (isTriggerDevEnabled) {
- const handle = await tasks.trigger('webhook-execution', payload)
- logger.info(
- `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
- handle.id
- } for ${foundWebhook.provider} webhook`
- )
- } else {
- void executeWebhookJob(payload).catch((error) => {
- logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
- })
- logger.info(
- `[${options.requestId}] Queued direct ${
- options.testMode ? 'TEST ' : ''
- }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
- )
- }
+ void executeWebhookJob(payload).catch((error) => {
+ logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
+ })
+ logger.info(
+ `[${options.requestId}] Queued direct ${
+ options.testMode ? 'TEST ' : ''
+ }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
+ )
}
if (foundWebhook.provider === 'microsoft-teams') {
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index 543cb4fc59..69fcf1eba7 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -2397,7 +2397,307 @@ export interface AirtableChange {
}
/**
- * Configure Gmail polling for a webhook
+ * Result of syncing webhooks for a credential set
+ */
+export interface CredentialSetWebhookSyncResult {
+ webhooks: Array<{
+ id: string
+ credentialId: string
+ isNew: boolean
+ }>
+ created: number
+ updated: number
+ deleted: number
+}
+
+/**
+ * Sync webhooks for a credential set.
+ *
+ * For credential sets, we create one webhook per credential in the set.
+ * Each webhook has its own state and credentialId.
+ *
+ * Path strategy:
+ * - Polling triggers (gmail, outlook): unique paths per credential (for independent polling)
+ * - External triggers (slack, etc.): shared path (external service sends to one URL)
+ *
+ * This function:
+ * 1. Gets all credentials in the credential set
+ * 2. Gets existing webhooks for this workflow+block with this credentialSetId
+ * 3. Creates webhooks for new credentials
+ * 4. Updates config for existing webhooks (preserving state)
+ * 5. Deletes webhooks for credentials no longer in the set
+ */
+export async function syncWebhooksForCredentialSet(params: {
+ workflowId: string
+ blockId: string
+ provider: string
+ basePath: string
+ credentialSetId: string
+ oauthProviderId: string
+ providerConfig: Record
+ requestId: string
+}): Promise {
+ const {
+ workflowId,
+ blockId,
+ provider,
+ basePath,
+ credentialSetId,
+ oauthProviderId,
+ providerConfig,
+ requestId,
+ } = params
+
+ const syncLogger = createLogger('CredentialSetWebhookSync')
+ syncLogger.info(
+ `[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
+ )
+
+ const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils')
+ const { nanoid } = await import('nanoid')
+
+ // Polling providers get unique paths per credential (for independent state)
+ // External webhook providers share the same path (external service sends to one URL)
+ const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
+ const useUniquePaths = pollingProviders.includes(provider)
+
+ // Get all credentials in the set
+ const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
+
+ if (credentials.length === 0) {
+ syncLogger.warn(
+ `[${requestId}] No credentials found in credential set ${credentialSetId} for provider ${oauthProviderId}`
+ )
+ return { webhooks: [], created: 0, updated: 0, deleted: 0 }
+ }
+
+ syncLogger.info(
+ `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}`
+ )
+
+ // Get existing webhooks for this workflow+block that belong to this credential set
+ const existingWebhooks = await db
+ .select()
+ .from(webhook)
+ .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
+
+ // Filter to only webhooks belonging to this credential set
+ const credentialSetWebhooks = existingWebhooks.filter((wh) => {
+ const config = wh.providerConfig as Record
+ return config?.credentialSetId === credentialSetId
+ })
+
+ syncLogger.info(
+ `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set`
+ )
+
+ // Build maps for efficient lookup
+ const existingByCredentialId = new Map()
+ for (const wh of credentialSetWebhooks) {
+ const config = wh.providerConfig as Record
+ if (config?.credentialId) {
+ existingByCredentialId.set(config.credentialId, wh)
+ }
+ }
+
+ const credentialIdsInSet = new Set(credentials.map((c) => c.credentialId))
+
+ const result: CredentialSetWebhookSyncResult = {
+ webhooks: [],
+ created: 0,
+ updated: 0,
+ deleted: 0,
+ }
+
+ // Process each credential in the set
+ for (const cred of credentials) {
+ const existingWebhook = existingByCredentialId.get(cred.credentialId)
+
+ if (existingWebhook) {
+ // Update existing webhook - preserve state fields
+ const existingConfig = existingWebhook.providerConfig as Record
+
+ const updatedConfig = {
+ ...providerConfig,
+ credentialId: cred.credentialId,
+ credentialSetId: credentialSetId,
+ // Preserve state fields from existing config
+ historyId: existingConfig.historyId,
+ lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
+ setupCompleted: existingConfig.setupCompleted,
+ externalId: existingConfig.externalId,
+ userId: cred.userId,
+ }
+
+ await db
+ .update(webhook)
+ .set({
+ providerConfig: updatedConfig,
+ isActive: true,
+ updatedAt: new Date(),
+ })
+ .where(eq(webhook.id, existingWebhook.id))
+
+ result.webhooks.push({
+ id: existingWebhook.id,
+ credentialId: cred.credentialId,
+ isNew: false,
+ })
+ result.updated++
+
+ syncLogger.debug(
+ `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}`
+ )
+ } else {
+ // Create new webhook for this credential
+ const webhookId = nanoid()
+ const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` : basePath
+
+ const newConfig = {
+ ...providerConfig,
+ credentialId: cred.credentialId,
+ credentialSetId: credentialSetId,
+ userId: cred.userId,
+ }
+
+ await db.insert(webhook).values({
+ id: webhookId,
+ workflowId,
+ blockId,
+ path: webhookPath,
+ provider,
+ providerConfig: newConfig,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+
+ result.webhooks.push({
+ id: webhookId,
+ credentialId: cred.credentialId,
+ isNew: true,
+ })
+ result.created++
+
+ syncLogger.debug(
+ `[${requestId}] Created webhook ${webhookId} for credential ${cred.credentialId}`
+ )
+ }
+ }
+
+ // Delete webhooks for credentials no longer in the set
+ for (const [credentialId, existingWebhook] of existingByCredentialId) {
+ if (!credentialIdsInSet.has(credentialId)) {
+ await db.delete(webhook).where(eq(webhook.id, existingWebhook.id))
+ result.deleted++
+
+ syncLogger.debug(
+ `[${requestId}] Deleted webhook ${existingWebhook.id} for removed credential ${credentialId}`
+ )
+ }
+ }
+
+ syncLogger.info(
+ `[${requestId}] Credential set webhook sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted`
+ )
+
+ return result
+}
+
+/**
+ * Sync all webhooks that use a specific credential set.
+ * Called when credential set membership changes (member added/removed).
+ *
+ * This finds all workflows with webhooks using this credential set and resyncs them.
+ */
+export async function syncAllWebhooksForCredentialSet(
+ credentialSetId: string,
+ requestId: string
+): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> {
+ const syncLogger = createLogger('CredentialSetMembershipSync')
+ syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`)
+
+ const { getProviderIdFromServiceId } = await import('@/lib/oauth')
+
+ // Find all webhooks that use this credential set
+ const allWebhooks = await db.select().from(webhook)
+
+ // Filter to webhooks with this credentialSetId in their providerConfig
+ const webhooksForSet = allWebhooks.filter((wh) => {
+ const config = wh.providerConfig as Record
+ return config?.credentialSetId === credentialSetId
+ })
+
+ if (webhooksForSet.length === 0) {
+ syncLogger.info(`[${requestId}] No webhooks found using credential set ${credentialSetId}`)
+ return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 }
+ }
+
+ // Group webhooks by workflow+block to find unique triggers
+ const triggerGroups = new Map()
+ for (const wh of webhooksForSet) {
+ const key = `${wh.workflowId}:${wh.blockId}`
+ // Keep the first webhook as representative (they all have same config)
+ if (!triggerGroups.has(key)) {
+ triggerGroups.set(key, wh)
+ }
+ }
+
+ syncLogger.info(
+ `[${requestId}] Found ${triggerGroups.size} triggers using credential set ${credentialSetId}`
+ )
+
+ let workflowsUpdated = 0
+ let totalCreated = 0
+ let totalDeleted = 0
+
+ for (const [key, representativeWebhook] of triggerGroups) {
+ if (!representativeWebhook.provider) {
+ syncLogger.warn(`[${requestId}] Skipping webhook without provider: ${key}`)
+ continue
+ }
+
+ const config = representativeWebhook.providerConfig as Record
+ const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider)
+
+ const { credentialId: _cId, userId: _uId, ...baseConfig } = config
+ const pathParts = representativeWebhook.path.split('-')
+ const basePath = `${pathParts[0]}-${pathParts[1]}`
+
+ try {
+ const result = await syncWebhooksForCredentialSet({
+ workflowId: representativeWebhook.workflowId,
+ blockId: representativeWebhook.blockId || '',
+ provider: representativeWebhook.provider,
+ basePath,
+ credentialSetId,
+ oauthProviderId,
+ providerConfig: baseConfig,
+ requestId,
+ })
+
+ workflowsUpdated++
+ totalCreated += result.created
+ totalDeleted += result.deleted
+
+ syncLogger.debug(
+ `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted`
+ )
+ } catch (error) {
+ syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error)
+ }
+ }
+
+ syncLogger.info(
+ `[${requestId}] Credential set membership sync complete: ${workflowsUpdated} workflows updated, ${totalCreated} webhooks created, ${totalDeleted} webhooks deleted`
+ )
+
+ return { workflowsUpdated, totalCreated, totalDeleted }
+}
+
+/**
+ * Configure Gmail polling for a webhook.
+ * Each webhook has its own credentialId (credential sets are fanned out at save time).
*/
export async function configureGmailPolling(webhookData: any, requestId: string): Promise {
const logger = createLogger('GmailWebhookSetup')
@@ -2406,46 +2706,30 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
try {
const providerConfig = (webhookData.providerConfig as Record) || {}
const credentialId: string | undefined = providerConfig.credentialId
- const credentialSetId: string | undefined = providerConfig.credentialSetId
- if (!credentialId && !credentialSetId) {
+ if (!credentialId) {
+ logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`)
+ return false
+ }
+
+ // Verify credential exists and get userId
+ const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
+ if (rows.length === 0) {
logger.error(
- `[${requestId}] Missing credentialId or credentialSetId for Gmail webhook ${webhookData.id}`
+ `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
- let effectiveUserId: string | undefined
+ const effectiveUserId = rows[0].userId
- if (credentialSetId) {
- const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils')
- const credentials = await getCredentialsForCredentialSet(credentialSetId, 'google-email')
- if (credentials.length === 0) {
- logger.error(
- `[${requestId}] No Gmail credentials found in credential set ${credentialSetId}`
- )
- return false
- }
- logger.info(
- `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Gmail credentials`
+ // Verify token can be refreshed
+ const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
+ if (!accessToken) {
+ logger.error(
+ `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
- } else {
- const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
- if (rows.length === 0) {
- logger.error(
- `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
- )
- return false
- }
-
- effectiveUserId = rows[0].userId
- const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
- if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
- )
- return false
- }
+ return false
}
const maxEmailsPerPoll =
@@ -2465,16 +2749,15 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
.set({
providerConfig: {
...providerConfig,
- ...(effectiveUserId ? { userId: effectiveUserId } : {}),
- ...(credentialId ? { credentialId } : {}),
- ...(credentialSetId ? { credentialSetId } : {}),
+ userId: effectiveUserId,
+ credentialId,
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
labelIds: providerConfig.labelIds || ['INBOX'],
labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE',
- lastCheckedTimestamp: now.toISOString(),
+ lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
@@ -2496,7 +2779,8 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
}
/**
- * Configure Outlook polling for a webhook
+ * Configure Outlook polling for a webhook.
+ * Each webhook has its own credentialId (credential sets are fanned out at save time).
*/
export async function configureOutlookPolling(
webhookData: any,
@@ -2508,73 +2792,54 @@ export async function configureOutlookPolling(
try {
const providerConfig = (webhookData.providerConfig as Record) || {}
const credentialId: string | undefined = providerConfig.credentialId
- const credentialSetId: string | undefined = providerConfig.credentialSetId
- if (!credentialId && !credentialSetId) {
+ if (!credentialId) {
+ logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`)
+ return false
+ }
+
+ // Verify credential exists and get userId
+ const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
+ if (rows.length === 0) {
logger.error(
- `[${requestId}] Missing credentialId or credentialSetId for Outlook webhook ${webhookData.id}`
+ `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
- let effectiveUserId: string | undefined
+ const effectiveUserId = rows[0].userId
- if (credentialSetId) {
- const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils')
- const credentials = await getCredentialsForCredentialSet(credentialSetId, 'outlook')
- if (credentials.length === 0) {
- logger.error(
- `[${requestId}] No Outlook credentials found in credential set ${credentialSetId}`
- )
- return false
- }
- logger.info(
- `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Outlook credentials`
+ // Verify token can be refreshed
+ const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
+ if (!accessToken) {
+ logger.error(
+ `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
- } else {
- const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
- if (rows.length === 0) {
- logger.error(
- `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
- )
- return false
- }
-
- effectiveUserId = rows[0].userId
- const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
- if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
- )
- return false
- }
+ return false
}
- const providerCfg = (webhookData.providerConfig as Record) || {}
-
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
- ...providerCfg,
- ...(effectiveUserId ? { userId: effectiveUserId } : {}),
- ...(credentialId ? { credentialId } : {}),
- ...(credentialSetId ? { credentialSetId } : {}),
+ ...providerConfig,
+ userId: effectiveUserId,
+ credentialId,
maxEmailsPerPoll:
- typeof providerCfg.maxEmailsPerPoll === 'string'
- ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25
- : providerCfg.maxEmailsPerPoll || 25,
+ typeof providerConfig.maxEmailsPerPoll === 'string'
+ ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
+ : providerConfig.maxEmailsPerPoll || 25,
pollingInterval:
- typeof providerCfg.pollingInterval === 'string'
- ? Number.parseInt(providerCfg.pollingInterval, 10) || 5
- : providerCfg.pollingInterval || 5,
- markAsRead: providerCfg.markAsRead || false,
- includeRawEmail: providerCfg.includeRawEmail || false,
- folderIds: providerCfg.folderIds || ['inbox'],
- folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE',
- lastCheckedTimestamp: now.toISOString(),
+ typeof providerConfig.pollingInterval === 'string'
+ ? Number.parseInt(providerConfig.pollingInterval, 10) || 5
+ : providerConfig.pollingInterval || 5,
+ markAsRead: providerConfig.markAsRead || false,
+ includeRawEmail: providerConfig.includeRawEmail || false,
+ folderIds: providerConfig.folderIds || ['inbox'],
+ folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
+ lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index af918c8b4e..5d91854c74 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -5,7 +5,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { parseMcpToolId } from '@/lib/mcp/utils'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
-import { CREDENTIAL_SET_USER_PREFIX } from '@/executor/variables/resolvers/credential-set'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types'
@@ -254,25 +253,8 @@ export async function executeTool(
try {
const baseUrl = getBaseUrl()
- // Prepare the token payload
- const tokenPayload: OAuthTokenPayload = {}
- const credentialValue = contextParams.credential as string
-
- if (credentialValue.startsWith(CREDENTIAL_SET_USER_PREFIX)) {
- const credentialAccountUserId = credentialValue.slice(CREDENTIAL_SET_USER_PREFIX.length)
- const providerId = tool?.oauth?.provider
- if (!providerId) {
- throw new Error(
- `Cannot resolve credential set user without providerId for tool ${toolId}`
- )
- }
- tokenPayload.credentialAccountUserId = credentialAccountUserId
- tokenPayload.providerId = providerId
- logger.info(
- `[${requestId}] Using credential set user: ${credentialAccountUserId}, provider: ${providerId}`
- )
- } else {
- tokenPayload.credentialId = credentialValue
+ const tokenPayload: OAuthTokenPayload = {
+ credentialId: contextParams.credential as string,
}
// Add workflowId if it exists in params, context, or executionContext
diff --git a/apps/sim/triggers/airtable/webhook.ts b/apps/sim/triggers/airtable/webhook.ts
index 45c8f6cf3a..8dca58c329 100644
--- a/apps/sim/triggers/airtable/webhook.ts
+++ b/apps/sim/triggers/airtable/webhook.ts
@@ -20,6 +20,7 @@ export const airtableWebhookTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
},
{
id: 'baseId',
diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts
index d731246248..261634f0cf 100644
--- a/apps/sim/triggers/constants.ts
+++ b/apps/sim/triggers/constants.ts
@@ -13,6 +13,7 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [
'setupScript', // Setup script code (e.g., Apps Script)
'triggerId', // Stored trigger ID
'selectedTriggerId', // Selected trigger from dropdown (multi-trigger blocks)
+ 'triggerConfig', // Aggregated trigger config (derived from individual fields)
]
/**
diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts
index cfda07a2d9..48f0eb5e56 100644
--- a/apps/sim/triggers/gmail/poller.ts
+++ b/apps/sim/triggers/gmail/poller.ts
@@ -1,10 +1,28 @@
import { createLogger } from '@sim/logger'
import { GmailIcon } from '@/components/icons'
+import { isCredentialSetValue } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { TriggerConfig } from '@/triggers/types'
const logger = createLogger('GmailPollingTrigger')
+// Gmail system labels that exist for all accounts (used as defaults for credential sets)
+const GMAIL_SYSTEM_LABELS = [
+ { id: 'INBOX', label: 'Inbox' },
+ { id: 'SENT', label: 'Sent' },
+ { id: 'DRAFT', label: 'Drafts' },
+ { id: 'SPAM', label: 'Spam' },
+ { id: 'TRASH', label: 'Trash' },
+ { id: 'STARRED', label: 'Starred' },
+ { id: 'IMPORTANT', label: 'Important' },
+ { id: 'UNREAD', label: 'Unread' },
+ { id: 'CATEGORY_PERSONAL', label: 'Category: Personal' },
+ { id: 'CATEGORY_SOCIAL', label: 'Category: Social' },
+ { id: 'CATEGORY_PROMOTIONS', label: 'Category: Promotions' },
+ { id: 'CATEGORY_UPDATES', label: 'Category: Updates' },
+ { id: 'CATEGORY_FORUMS', label: 'Category: Forums' },
+]
+
export const gmailPollingTrigger: TriggerConfig = {
id: 'gmail_poller',
name: 'Gmail Email Trigger',
@@ -23,6 +41,7 @@ export const gmailPollingTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
},
{
id: 'labelIds',
@@ -41,6 +60,10 @@ export const gmailPollingTrigger: TriggerConfig = {
// Return a sentinel to prevent infinite retry loops when credential is missing
throw new Error('No Gmail credential selected')
}
+ // Return default system labels for credential sets (can't fetch user-specific labels for a pool)
+ if (isCredentialSetValue(credentialId)) {
+ return GMAIL_SYSTEM_LABELS
+ }
try {
const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`)
if (!response.ok) {
diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts
index 3a3386e9cc..5262f5c6d6 100644
--- a/apps/sim/triggers/jira/utils.ts
+++ b/apps/sim/triggers/jira/utils.ts
@@ -44,6 +44,7 @@ export const jiraWebhookSubBlocks: SubBlockConfig[] = [
placeholder: 'Select Jira account',
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
},
{
id: 'webhookUrlDisplay',
diff --git a/apps/sim/triggers/microsoftteams/chat_webhook.ts b/apps/sim/triggers/microsoftteams/chat_webhook.ts
index 340b2a567b..543a493efc 100644
--- a/apps/sim/triggers/microsoftteams/chat_webhook.ts
+++ b/apps/sim/triggers/microsoftteams/chat_webhook.ts
@@ -41,6 +41,7 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts
index 9beeba252c..ef2c8bd04d 100644
--- a/apps/sim/triggers/outlook/poller.ts
+++ b/apps/sim/triggers/outlook/poller.ts
@@ -1,10 +1,22 @@
import { createLogger } from '@sim/logger'
import { OutlookIcon } from '@/components/icons'
+import { isCredentialSetValue } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { TriggerConfig } from '@/triggers/types'
const logger = createLogger('OutlookPollingTrigger')
+// Outlook well-known folders that exist for all accounts (used as defaults for credential sets)
+const OUTLOOK_SYSTEM_FOLDERS = [
+ { id: 'inbox', label: 'Inbox' },
+ { id: 'drafts', label: 'Drafts' },
+ { id: 'sentitems', label: 'Sent Items' },
+ { id: 'deleteditems', label: 'Deleted Items' },
+ { id: 'junkemail', label: 'Junk Email' },
+ { id: 'archive', label: 'Archive' },
+ { id: 'outbox', label: 'Outbox' },
+]
+
export const outlookPollingTrigger: TriggerConfig = {
id: 'outlook_poller',
name: 'Outlook Email Trigger',
@@ -23,6 +35,7 @@ export const outlookPollingTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
},
{
id: 'folderIds',
@@ -40,6 +53,10 @@ export const outlookPollingTrigger: TriggerConfig = {
if (!credentialId) {
throw new Error('No Outlook credential selected')
}
+ // Return default system folders for credential sets (can't fetch user-specific folders for a pool)
+ if (isCredentialSetValue(credentialId)) {
+ return OUTLOOK_SYSTEM_FOLDERS
+ }
try {
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (!response.ok) {
diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts
index 68657d588b..1802bfdf95 100644
--- a/apps/sim/triggers/webflow/collection_item_changed.ts
+++ b/apps/sim/triggers/webflow/collection_item_changed.ts
@@ -20,6 +20,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts
index a4eaa831a8..46a4da9c69 100644
--- a/apps/sim/triggers/webflow/collection_item_created.ts
+++ b/apps/sim/triggers/webflow/collection_item_created.ts
@@ -33,6 +33,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts
index 6b50f63986..535030e030 100644
--- a/apps/sim/triggers/webflow/collection_item_deleted.ts
+++ b/apps/sim/triggers/webflow/collection_item_deleted.ts
@@ -20,6 +20,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts
index e0a0725e8c..fc84078ff9 100644
--- a/apps/sim/triggers/webflow/form_submission.ts
+++ b/apps/sim/triggers/webflow/form_submission.ts
@@ -20,6 +20,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
+ supportsCredentialSets: true,
},
{
id: 'siteId',
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index ac01cacddf..68173a7957 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -1778,6 +1778,8 @@ export const usageLog = pgTable(
})
)
+export const credentialSetTypeEnum = pgEnum('credential_set_type', ['all', 'specific'])
+
export const credentialSet = pgTable(
'credential_set',
{
@@ -1787,6 +1789,8 @@ export const credentialSet = pgTable(
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
+ type: credentialSetTypeEnum('type').notNull().default('all'),
+ providerId: text('provider_id'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
@@ -1800,14 +1804,13 @@ export const credentialSet = pgTable(
table.organizationId,
table.name
),
+ typeIdx: index('credential_set_type_idx').on(table.type),
})
)
export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [
'active',
'pending',
- 'revoked',
- 'credentials_missing',
])
export const credentialSetMember = pgTable(
From 1db3e33cf61b22555b49e728505a0b00e2d518dc Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Mon, 5 Jan 2026 17:05:26 -0800
Subject: [PATCH 03/21] return data to render credential set in block preview
---
apps/sim/hooks/queries/oauth-credentials.ts | 29 ++++++++++++++++++---
1 file changed, 25 insertions(+), 4 deletions(-)
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
index f692321653..89b9913eb6 100644
--- a/apps/sim/hooks/queries/oauth-credentials.ts
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
+import { CREDENTIAL_SET } from '@/executor/constants'
+import { useCredentialSetMemberships } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
@@ -61,14 +63,25 @@ export function useOAuthCredentialDetail(
}
export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
+ // Check if this is a credential set value
+ const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
+ const credentialSetId = isCredentialSet
+ ? credentialId?.slice(CREDENTIAL_SET.PREFIX.length)
+ : undefined
+
+ // Fetch credential set memberships if this is a credential set
+ const { data: memberships = [], isFetching: membershipsLoading } = useCredentialSetMemberships()
+
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
providerId,
- Boolean(providerId)
+ Boolean(providerId) && !isCredentialSet
)
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
- const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
+ const shouldFetchDetail = Boolean(
+ credentialId && !selectedCredential && providerId && workflowId && !isCredentialSet
+ )
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
shouldFetchDetail ? credentialId : undefined,
@@ -78,11 +91,19 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
const hasForeignMeta = foreignCredentials.length > 0
- const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
+ // For credential sets, find the matching membership and use its name
+ const credentialSetName = credentialSetId
+ ? memberships.find((m) => m.credentialSetId === credentialSetId)?.credentialSetName
+ : undefined
+
+ const displayName =
+ credentialSetName ??
+ selectedCredential?.name ??
+ (hasForeignMeta ? 'Saved by collaborator' : null)
return {
displayName,
- isLoading: credentialsLoading || foreignLoading,
+ isLoading: credentialsLoading || foreignLoading || (isCredentialSet && membershipsLoading),
hasForeignMeta,
}
}
From 2d5cc9b4ad3ca1161ef9d482e491ae0a983453b3 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Mon, 5 Jan 2026 20:32:05 -0800
Subject: [PATCH 04/21] progress
---
.../app/api/auth/oauth/disconnect/route.ts | 47 +-
.../api/credential-sets/[id]/invite/route.ts | 54 +-
.../api/credential-sets/[id]/members/route.ts | 24 +-
.../credential-sets/invite/[token]/route.ts | 86 ++-
.../api/credential-sets/memberships/route.ts | 79 ++-
apps/sim/app/api/webhooks/route.ts | 8 +
.../sim/app/api/workflows/[id]/state/route.ts | 2 +
.../credential-sets/credential-sets.tsx | 507 +++++++++---------
.../settings-modal/settings-modal.tsx | 10 +-
apps/sim/background/webhook-execution.ts | 7 -
apps/sim/blocks/blocks/webhook.ts | 2 -
.../components/emails/invitations/index.ts | 1 +
.../polling-group-invitation-email.tsx | 55 ++
apps/sim/components/emails/render.ts | 19 +
apps/sim/components/emails/subjects.ts | 3 +
apps/sim/hooks/queries/credential-sets.ts | 21 +
apps/sim/lib/auth/auth.ts | 92 ++++
apps/sim/lib/webhooks/utils.server.ts | 40 +-
apps/sim/lib/workflows/persistence/utils.ts | 1 +
apps/sim/triggers/airtable/webhook.ts | 1 -
apps/sim/triggers/jira/utils.ts | 1 -
.../triggers/microsoftteams/chat_webhook.ts | 1 -
.../webflow/collection_item_changed.ts | 1 -
.../webflow/collection_item_created.ts | 1 -
.../webflow/collection_item_deleted.ts | 1 -
apps/sim/triggers/webflow/form_submission.ts | 1 -
packages/db/schema.ts | 6 +
27 files changed, 730 insertions(+), 341 deletions(-)
create mode 100644 apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts
index 5050e86172..2cbb7ad60b 100644
--- a/apps/sim/app/api/auth/oauth/disconnect/route.ts
+++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
-import { account } from '@sim/db/schema'
+import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
+import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
export const dynamic = 'force-dynamic'
@@ -74,6 +75,50 @@ export async function POST(request: NextRequest) {
)
}
+ // Sync webhooks for all credential sets the user is a member of
+ // This removes webhooks that were using the disconnected credential
+ const userMemberships = await db
+ .select({
+ id: credentialSetMember.id,
+ credentialSetId: credentialSetMember.credentialSetId,
+ providerId: credentialSet.providerId,
+ type: credentialSet.type,
+ })
+ .from(credentialSetMember)
+ .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
+ .where(
+ and(
+ eq(credentialSetMember.userId, session.user.id),
+ eq(credentialSetMember.status, 'active')
+ )
+ )
+
+ for (const membership of userMemberships) {
+ // Only sync if the credential set matches this provider or is 'all' type
+ const matchesProvider =
+ membership.type === 'all' ||
+ membership.providerId === provider ||
+ membership.providerId === providerId ||
+ (membership.providerId && provider.startsWith(membership.providerId))
+
+ if (matchesProvider) {
+ try {
+ await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
+ logger.info(`[${requestId}] Synced webhooks after credential disconnect`, {
+ credentialSetId: membership.credentialSetId,
+ provider,
+ })
+ } catch (error) {
+ // Log but don't fail the disconnect - credential is already removed
+ logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, {
+ credentialSetId: membership.credentialSetId,
+ provider,
+ error,
+ })
+ }
+ }
+ }
+
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
index 147cf49363..7c911b4075 100644
--- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
@@ -1,10 +1,13 @@
import { db } from '@sim/db'
-import { credentialSet, credentialSetInvitation, member } from '@sim/db/schema'
+import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
+import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('CredentialSetInvite')
@@ -18,6 +21,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
id: credentialSet.id,
organizationId: credentialSet.organizationId,
name: credentialSet.name,
+ providerId: credentialSet.providerId,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
@@ -98,12 +102,58 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
await db.insert(credentialSetInvitation).values(invitation)
- const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}`
+ const inviteUrl = `${getBaseUrl()}/credential-account/${token}`
+
+ // Send email if email address was provided
+ if (email) {
+ try {
+ // Get inviter name
+ const [inviter] = await db
+ .select({ name: user.name })
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .limit(1)
+
+ // Get organization name
+ const [org] = await db
+ .select({ name: organization.name })
+ .from(organization)
+ .where(eq(organization.id, result.set.organizationId))
+ .limit(1)
+
+ const provider = (result.set.providerId as 'gmail' | 'outlook') || 'gmail'
+ const emailHtml = await renderPollingGroupInvitationEmail({
+ inviterName: inviter?.name || 'A team member',
+ organizationName: org?.name || 'your organization',
+ pollingGroupName: result.set.name,
+ provider,
+ inviteLink: inviteUrl,
+ })
+
+ const emailResult = await sendEmail({
+ to: email,
+ subject: getEmailSubject('polling-group-invitation'),
+ html: emailHtml,
+ emailType: 'transactional',
+ })
+
+ if (!emailResult.success) {
+ logger.warn('Failed to send invitation email', {
+ email,
+ error: emailResult.message,
+ })
+ }
+ } catch (emailError) {
+ logger.error('Error sending invitation email', emailError)
+ // Don't fail the invitation creation if email fails
+ }
+ }
logger.info('Created credential set invitation', {
credentialSetId: id,
invitationId: invitation.id,
userId: session.user.id,
+ emailSent: !!email,
})
return NextResponse.json({
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
index 7f92d46fe1..bc62e29873 100644
--- a/apps/sim/app/api/credential-sets/[id]/members/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -152,24 +152,24 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
- await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
+ const requestId = crypto.randomUUID().slice(0, 8)
- logger.info('Removed member from credential set', {
- credentialSetId: id,
- memberId,
- userId: session.user.id,
- })
+ // Use transaction to ensure member deletion + webhook sync are atomic
+ await db.transaction(async (tx) => {
+ await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
- try {
- const requestId = crypto.randomUUID().slice(0, 8)
- const syncResult = await syncAllWebhooksForCredentialSet(id, requestId)
+ const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx)
logger.info('Synced webhooks after member removed', {
credentialSetId: id,
...syncResult,
})
- } catch (syncError) {
- logger.error('Error syncing webhooks after member removed', syncError)
- }
+ })
+
+ logger.info('Removed member from credential set', {
+ credentialSetId: id,
+ memberId,
+ userId: session.user.id,
+ })
return NextResponse.json({ success: true })
} catch (error) {
diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
index a0f8b0e664..de9ad6df40 100644
--- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts
+++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
@@ -91,10 +91,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
}
- if (invitation.email && invitation.email !== session.user.email) {
- return NextResponse.json({ error: 'Email does not match invitation' }, { status: 400 })
- }
-
const existingMember = await db
.select()
.from(credentialSetMember)
@@ -114,64 +110,66 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
}
const now = new Date()
- await db.insert(credentialSetMember).values({
- id: crypto.randomUUID(),
- credentialSetId: invitation.credentialSetId,
- userId: session.user.id,
- status: 'active',
- joinedAt: now,
- invitedBy: invitation.invitedBy,
- createdAt: now,
- updatedAt: now,
- })
+ const requestId = crypto.randomUUID().slice(0, 8)
- await db
- .update(credentialSetInvitation)
- .set({
- status: 'accepted',
- acceptedAt: now,
- acceptedByUserId: session.user.id,
+ // Use transaction to ensure membership + invitation update + webhook sync are atomic
+ await db.transaction(async (tx) => {
+ await tx.insert(credentialSetMember).values({
+ id: crypto.randomUUID(),
+ credentialSetId: invitation.credentialSetId,
+ userId: session.user.id,
+ status: 'active',
+ joinedAt: now,
+ invitedBy: invitation.invitedBy,
+ createdAt: now,
+ updatedAt: now,
})
- .where(eq(credentialSetInvitation.id, invitation.id))
- // Clean up all other pending invitations for the same credential set and email
- // This prevents duplicate invites from showing up after accepting one
- if (invitation.email) {
- await db
+ await tx
.update(credentialSetInvitation)
.set({
status: 'accepted',
acceptedAt: now,
acceptedByUserId: session.user.id,
})
- .where(
- and(
- eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
- eq(credentialSetInvitation.email, invitation.email),
- eq(credentialSetInvitation.status, 'pending')
- )
- )
- }
+ .where(eq(credentialSetInvitation.id, invitation.id))
- logger.info('Accepted credential set invitation', {
- invitationId: invitation.id,
- credentialSetId: invitation.credentialSetId,
- userId: session.user.id,
- })
+ // Clean up all other pending invitations for the same credential set and email
+ // This prevents duplicate invites from showing up after accepting one
+ if (invitation.email) {
+ await tx
+ .update(credentialSetInvitation)
+ .set({
+ status: 'accepted',
+ acceptedAt: now,
+ acceptedByUserId: session.user.id,
+ })
+ .where(
+ and(
+ eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
+ eq(credentialSetInvitation.email, invitation.email),
+ eq(credentialSetInvitation.status, 'pending')
+ )
+ )
+ }
- try {
- const requestId = crypto.randomUUID().slice(0, 8)
+ // Sync webhooks within the transaction
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
- requestId
+ requestId,
+ tx
)
logger.info('Synced webhooks after member joined', {
credentialSetId: invitation.credentialSetId,
...syncResult,
})
- } catch (syncError) {
- logger.error('Error syncing webhooks after member joined', syncError)
- }
+ })
+
+ logger.info('Accepted credential set invitation', {
+ invitationId: invitation.id,
+ credentialSetId: invitation.credentialSetId,
+ userId: session.user.id,
+ })
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts
index 33a60c2af5..aaaeecfc4f 100644
--- a/apps/sim/app/api/credential-sets/memberships/route.ts
+++ b/apps/sim/app/api/credential-sets/memberships/route.ts
@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetMember, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { eq } from 'drizzle-orm'
-import { NextResponse } from 'next/server'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
+import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMemberships')
@@ -37,3 +38,77 @@ export async function GET() {
return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 })
}
}
+
+/**
+ * Leave a credential set (self-revocation).
+ * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up.
+ */
+export async function DELETE(req: NextRequest) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { searchParams } = new URL(req.url)
+ const credentialSetId = searchParams.get('credentialSetId')
+
+ if (!credentialSetId) {
+ return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 })
+ }
+
+ try {
+ const requestId = crypto.randomUUID().slice(0, 8)
+
+ // Use transaction to ensure revocation + webhook sync are atomic
+ await db.transaction(async (tx) => {
+ // Find and verify membership
+ const [membership] = await tx
+ .select()
+ .from(credentialSetMember)
+ .where(
+ and(
+ eq(credentialSetMember.credentialSetId, credentialSetId),
+ eq(credentialSetMember.userId, session.user.id)
+ )
+ )
+ .limit(1)
+
+ if (!membership) {
+ throw new Error('Not a member of this credential set')
+ }
+
+ if (membership.status === 'revoked') {
+ throw new Error('Already left this credential set')
+ }
+
+ // Set status to 'revoked' - this immediately blocks credential from being used
+ await tx
+ .update(credentialSetMember)
+ .set({
+ status: 'revoked',
+ updatedAt: new Date(),
+ })
+ .where(eq(credentialSetMember.id, membership.id))
+
+ // Sync webhooks to remove this user's credential webhooks
+ const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx)
+ logger.info('Synced webhooks after member left', {
+ credentialSetId,
+ userId: session.user.id,
+ ...syncResult,
+ })
+ })
+
+ logger.info('User left credential set', {
+ credentialSetId,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to leave credential set'
+ logger.error('Error leaving credential set', error)
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts
index 5456eb631b..28f3180b3a 100644
--- a/apps/sim/app/api/webhooks/route.ts
+++ b/apps/sim/app/api/webhooks/route.ts
@@ -573,6 +573,10 @@ export async function POST(request: NextRequest) {
blockId,
provider,
providerConfig: resolvedProviderConfig,
+ credentialSetId:
+ ((resolvedProviderConfig as Record)?.credentialSetId as
+ | string
+ | null) || null,
isActive: true,
updatedAt: new Date(),
})
@@ -596,6 +600,10 @@ export async function POST(request: NextRequest) {
path: finalPath,
provider,
providerConfig: resolvedProviderConfig,
+ credentialSetId:
+ ((resolvedProviderConfig as Record)?.credentialSetId as
+ | string
+ | null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts
index 43957ad95a..5995182ad6 100644
--- a/apps/sim/app/api/workflows/[id]/state/route.ts
+++ b/apps/sim/app/api/workflows/[id]/state/route.ts
@@ -374,6 +374,7 @@ async function upsertWebhookRecord(
return
}
+ const providerConfig = metadata.providerConfig as Record
await db.insert(webhook).values({
id: webhookId,
workflowId,
@@ -381,6 +382,7 @@ async function upsertWebhookRecord(
path: metadata.triggerPath,
provider: metadata.provider,
providerConfig: metadata.providerConfig,
+ credentialSetId: (providerConfig?.credentialSetId as string | null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 8f38c485c6..fd847cfabf 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -1,14 +1,13 @@
'use client'
-import { useCallback, useMemo, useState } from 'react'
+import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ArrowLeft, Check, Copy, Loader2, Plus, Trash2, User } from 'lucide-react'
+import { ArrowLeft, Loader2, LogOut, Plus, Trash2, User } from 'lucide-react'
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
- Combobox,
Input,
Label,
Modal,
@@ -16,11 +15,12 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
+ Textarea,
} from '@/components/emcn'
+import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
-import { OAUTH_PROVIDERS } from '@/lib/oauth'
import { getUserRole } from '@/lib/workspaces/organization'
import {
type CredentialSet,
@@ -32,12 +32,13 @@ import {
useCredentialSetMembers,
useCredentialSetMemberships,
useCredentialSets,
+ useLeaveCredentialSet,
useRemoveCredentialSetMember,
} from '@/hooks/queries/credential-sets'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
-const logger = createLogger('CredentialSets')
+const logger = createLogger('EmailPolling')
function CredentialSetsSkeleton() {
return (
@@ -78,43 +79,89 @@ export function CredentialSets() {
const [viewingSet, setViewingSet] = useState(null)
const [newSetName, setNewSetName] = useState('')
const [newSetDescription, setNewSetDescription] = useState('')
- const [newSetType, setNewSetType] = useState('all')
- const [newSetProviderId, setNewSetProviderId] = useState('')
- const [inviteEmail, setInviteEmail] = useState('')
- const [copiedLink, setCopiedLink] = useState(false)
- const [generatedInviteUrl, setGeneratedInviteUrl] = useState(null)
+ const [newSetProvider, setNewSetProvider] = useState<'gmail' | 'outlook'>('gmail')
+ const [inviteEmails, setInviteEmails] = useState('')
+ const [isDragging, setIsDragging] = useState(false)
+ const [leavingMembership, setLeavingMembership] = useState<{
+ credentialSetId: string
+ name: string
+ } | null>(null)
const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id)
const removeMember = useRemoveCredentialSetMember()
+ const leaveCredentialSet = useLeaveCredentialSet()
- const providerOptions = useMemo(() => {
- const options: { label: string; value: string }[] = []
- for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) {
- if (provider.services) {
- for (const [, service] of Object.entries(provider.services)) {
- options.push({
- label: service.name,
- value: service.providerId,
+ const getProviderName = useCallback((providerId: string) => {
+ if (providerId === 'gmail') return 'Gmail'
+ if (providerId === 'outlook') return 'Outlook'
+ return providerId
+ }, [])
+
+ const extractEmailsFromText = useCallback((text: string): string[] => {
+ // Match email patterns in text
+ const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
+ const matches = text.match(emailRegex) || []
+ // Deduplicate and return
+ return [...new Set(matches.map((e) => e.toLowerCase()))]
+ }, [])
+
+ const handleFileDrop = useCallback(
+ async (file: File) => {
+ try {
+ const text = await file.text()
+ const emails = extractEmailsFromText(text)
+ if (emails.length > 0) {
+ setInviteEmails((prev) => {
+ const existing = prev
+ .split(/[,\n]/)
+ .map((e) => e.trim())
+ .filter((e) => e.length > 0)
+ const combined = [...new Set([...existing, ...emails])]
+ return combined.join('\n')
})
}
+ } catch (error) {
+ logger.error('Error reading dropped file', error)
}
- }
- return options.sort((a, b) => a.label.localeCompare(b.label))
+ },
+ [extractEmailsFromText]
+ )
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ setIsDragging(true)
}, [])
- const getProviderName = useCallback((providerId: string) => {
- for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) {
- if (provider.services) {
- for (const [, service] of Object.entries(provider.services)) {
- if (service.providerId === providerId) {
- return service.name
- }
- }
- }
- }
- return providerId
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
}, [])
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+
+ const files = Array.from(e.dataTransfer.files)
+ const validFiles = files.filter(
+ (f) =>
+ f.type === 'text/csv' ||
+ f.type === 'text/plain' ||
+ f.name.endsWith('.csv') ||
+ f.name.endsWith('.txt')
+ )
+
+ for (const file of validFiles) {
+ await handleFileDrop(file)
+ }
+ },
+ [handleFileDrop]
+ )
+
const handleRemoveMember = useCallback(
async (memberId: string) => {
if (!viewingSet) return
@@ -130,6 +177,20 @@ export function CredentialSets() {
[viewingSet, removeMember]
)
+ const handleLeave = useCallback((credentialSetId: string, name: string) => {
+ setLeavingMembership({ credentialSetId, name })
+ }, [])
+
+ const confirmLeave = useCallback(async () => {
+ if (!leavingMembership) return
+ try {
+ await leaveCredentialSet.mutateAsync(leavingMembership.credentialSetId)
+ setLeavingMembership(null)
+ } catch (error) {
+ logger.error('Failed to leave polling group', error)
+ }
+ }, [leavingMembership, leaveCredentialSet])
+
const handleAcceptInvitation = useCallback(
async (token: string) => {
try {
@@ -143,73 +204,53 @@ export function CredentialSets() {
const handleCreateCredentialSet = useCallback(async () => {
if (!newSetName.trim() || !activeOrganization?.id) return
- if (newSetType === 'specific' && !newSetProviderId) return
try {
await createCredentialSet.mutateAsync({
organizationId: activeOrganization.id,
name: newSetName.trim(),
description: newSetDescription.trim() || undefined,
- type: newSetType,
- providerId: newSetType === 'specific' ? newSetProviderId : undefined,
+ type: 'specific' as CredentialSetType,
+ providerId: newSetProvider,
})
setShowCreateModal(false)
setNewSetName('')
setNewSetDescription('')
- setNewSetType('all')
- setNewSetProviderId('')
+ setNewSetProvider('gmail')
} catch (error) {
- logger.error('Failed to create credential set', error)
+ logger.error('Failed to create polling group', error)
}
- }, [
- newSetName,
- newSetDescription,
- newSetType,
- newSetProviderId,
- activeOrganization?.id,
- createCredentialSet,
- ])
+ }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet])
const handleCreateInvite = useCallback(async () => {
if (!selectedSetId) return
+
+ // Parse comma-separated or newline-separated emails
+ const emails = inviteEmails
+ .split(/[,\n]/)
+ .map((e) => e.trim())
+ .filter((e) => e.length > 0 && e.includes('@'))
+
+ if (emails.length === 0) return
+
try {
- const result = await createInvitation.mutateAsync({
- credentialSetId: selectedSetId,
- email: inviteEmail.trim() || undefined,
- })
- const inviteUrl = result.invitation?.inviteUrl
- if (inviteUrl) {
- setGeneratedInviteUrl(inviteUrl)
- try {
- await navigator.clipboard.writeText(inviteUrl)
- setCopiedLink(true)
- setTimeout(() => setCopiedLink(false), 2000)
- } catch {
- // Clipboard failed, URL is shown in modal for manual copy
- }
+ for (const email of emails) {
+ await createInvitation.mutateAsync({
+ credentialSetId: selectedSetId,
+ email,
+ })
}
- setInviteEmail('')
+ setInviteEmails('')
+ setShowInviteModal(false)
+ setSelectedSetId(null)
} catch (error) {
- logger.error('Failed to create invitation', error)
+ logger.error('Failed to create invitations', error)
}
- }, [selectedSetId, inviteEmail, createInvitation])
-
- const handleCopyInviteUrl = useCallback(async () => {
- if (!generatedInviteUrl) return
- try {
- await navigator.clipboard.writeText(generatedInviteUrl)
- setCopiedLink(true)
- setTimeout(() => setCopiedLink(false), 2000)
- } catch {
- // Fallback: select the input text
- }
- }, [generatedInviteUrl])
+ }, [selectedSetId, inviteEmails, createInvitation])
const handleCloseInviteModal = useCallback(() => {
setShowInviteModal(false)
- setInviteEmail('')
+ setInviteEmails('')
setSelectedSetId(null)
- setGeneratedInviteUrl(null)
- setCopiedLink(false)
}, [])
if (membershipsLoading || invitationsLoading) {
@@ -220,7 +261,7 @@ export function CredentialSets() {
const hasNoContent =
invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0
- // Detail view for a credential set
+ // Detail view for a polling group
if (viewingSet) {
const activeMembers = members.filter((m) => m.status === 'active')
const pendingMembers = members.filter((m) => m.status === 'pending')
@@ -236,9 +277,7 @@ export function CredentialSets() {
{viewingSet.name}
- {viewingSet.type === 'all'
- ? 'All Integrations'
- : `${getProviderName(viewingSet.providerId || '')} Only`}
+ {getProviderName(viewingSet.providerId || '')}
@@ -325,7 +364,7 @@ export function CredentialSets() {
No members yet
- Invite people to join this credential set
+ Invite people to join this polling group
)}
@@ -341,79 +380,58 @@ export function CredentialSets() {
}}
>
- Invite Member
+ Add Member
- Invite to Credential Set
+ Add Members
- {generatedInviteUrl ? (
- <>
-
-
Invite Link
-
-
(e.target as HTMLInputElement).select()}
- />
-
- {copiedLink ? (
-
- ) : (
-
- )}
-
+
+
Email Addresses
+
-
- Share this link with the person you want to invite.
-
- >
- ) : (
- <>
-
- Email (optional)
- setInviteEmail(e.target.value)}
- placeholder='Leave empty for a shareable link'
- />
-
-
- Generate an invite link to share.
-
- >
- )}
+ )}
+
+
+
+ Invitees will receive an email with a link to connect their account.
+
- {generatedInviteUrl ? (
-
- Done
-
- ) : (
- <>
-
- Cancel
-
-
- {createInvitation.isPending ? (
-
- ) : (
- 'Generate Link'
- )}
-
- >
- )}
+
+ Cancel
+
+
+ {createInvitation.isPending ? (
+
+ ) : (
+ 'Send Invites'
+ )}
+
@@ -426,10 +444,10 @@ export function CredentialSets() {
{hasNoContent && !canManageCredentialSets && (
- You're not a member of any credential sets yet.
+ You're not a member of any polling groups yet.
- When someone invites you to a credential set, it will appear here.
+ When someone invites you to a polling group, it will appear here.
)}
@@ -482,6 +500,21 @@ export function CredentialSets() {
{membership.organizationName}
+
+ handleLeave(membership.credentialSetId, membership.credentialSetName)
+ }
+ disabled={leaveCredentialSet.isPending}
+ className='text-[var(--text-secondary)] hover:text-[var(--text-error)]'
+ >
+ {leaveCredentialSet.isPending ? (
+
+ ) : (
+
+ )}
+
))}
@@ -505,7 +538,7 @@ export function CredentialSets() {
) : ownedSets.length === 0 ? (
- No credential sets created yet
+ No polling groups created yet
) : (
@@ -521,9 +554,7 @@ export function CredentialSets() {
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
- {set.type === 'specific' && set.providerId && (
- <> · {getProviderName(set.providerId)}>
- )}
+ {set.providerId && <> · {getProviderName(set.providerId)}>}
- Invite
+ Add Members
))
@@ -544,7 +575,7 @@ export function CredentialSets() {
- Create Credential Set
+ Create Polling Group
@@ -560,52 +591,34 @@ export function CredentialSets() {
setNewSetDescription(e.target.value)}
- placeholder='e.g., Credentials for marketing automations'
+ placeholder='e.g., Poll emails for marketing automations'
/>
-
Type
+
Email Provider
{
- setNewSetType('all')
- setNewSetProviderId('')
- }}
+ variant={newSetProvider === 'gmail' ? 'active' : 'default'}
+ onClick={() => setNewSetProvider('gmail')}
className='flex-1'
>
- All Integrations
+
+ Gmail
setNewSetType('specific')}
+ variant={newSetProvider === 'outlook' ? 'active' : 'default'}
+ onClick={() => setNewSetProvider('outlook')}
className='flex-1'
>
- Specific Integration
+
+ Outlook
- {newSetType === 'all'
- ? 'Members share all their connected credentials'
- : 'Members share only credentials for a specific integration'}
+ Members will connect their {newSetProvider === 'gmail' ? 'Gmail' : 'Outlook'}{' '}
+ account for email polling
- {newSetType === 'specific' && (
-
- Integration
- p.value === newSetProviderId)?.label ||
- newSetProviderId
- }
- selectedValue={newSetProviderId}
- onChange={(value) => setNewSetProviderId(value)}
- placeholder='Select an integration'
- filterOptions
- />
-
- )}
@@ -615,11 +628,7 @@ export function CredentialSets() {
{createCredentialSet.isPending ? (
@@ -633,73 +642,79 @@ export function CredentialSets() {
- Invite to Credential Set
+ Add Members
- {generatedInviteUrl ? (
- <>
-
-
Invite Link
-
-
(e.target as HTMLInputElement).select()}
- />
-
- {copiedLink ? (
-
- ) : (
-
- )}
-
+
+
Email Addresses
+
-
- Share this link with the person you want to invite.
-
- >
+ )}
+
+
+
+ Invitees will receive an email with a link to connect their account.
+
+
+
+
+
+ Cancel
+
+
+ {createInvitation.isPending ? (
+
) : (
- <>
-
- Email (optional)
- setInviteEmail(e.target.value)}
- placeholder='Leave empty for a shareable link'
- />
-
-
- Generate an invite link to share.
-
- >
+ 'Send Invites'
)}
-
+
+
+
+
+
+ setLeavingMembership(null)}>
+
+ Leave Polling Group
+
+
+ Are you sure you want to leave{' '}
+
+ {leavingMembership?.name}
+
+ ? Your email account will no longer be polled in workflows using this group.
+
- {generatedInviteUrl ? (
-
- Done
-
- ) : (
- <>
-
- Cancel
-
-
- {createInvitation.isPending ? (
-
- ) : (
- 'Generate Link'
- )}
-
- >
- )}
+ setLeavingMembership(null)}>
+ Cancel
+
+
+ {leaveCredentialSet.isPending ? 'Leaving...' : 'Leave'}
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index 37c876350e..1853206f8a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
-import { Files, KeySquare, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
+import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react'
import {
Card,
Connections,
@@ -100,7 +100,6 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
const allNavigationItems: NavigationItem[] = [
{ id: 'general', label: 'General', icon: Settings, section: 'account' },
{ id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
- { id: 'credential-sets', label: 'Credential Sets', icon: Users, section: 'account' },
{
id: 'subscription',
label: 'Subscription',
@@ -119,6 +118,13 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
+ {
+ id: 'credential-sets',
+ label: 'Email Polling',
+ icon: Mail,
+ section: 'system',
+ requiresTeam: true,
+ },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index a8f1bd3e36..b0447e8a00 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -252,10 +252,6 @@ async function executeWebhookJobInternal(
},
}
- if (payload.credentialId && typeof airtableInput === 'object') {
- airtableInput.currentUser = payload.credentialId
- }
-
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
@@ -516,9 +512,6 @@ async function executeWebhookJobInternal(
}
const triggerInput = input || {}
- if (payload.credentialId && typeof triggerInput === 'object') {
- triggerInput.currentUser = payload.credentialId
- }
const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
diff --git a/apps/sim/blocks/blocks/webhook.ts b/apps/sim/blocks/blocks/webhook.ts
index 493ce0850f..a7a9a1a737 100644
--- a/apps/sim/blocks/blocks/webhook.ts
+++ b/apps/sim/blocks/blocks/webhook.ts
@@ -98,7 +98,6 @@ export const WebhookBlock: BlockConfig = {
placeholder: 'Select Gmail account',
condition: { field: 'webhookProvider', value: 'gmail' },
required: true,
- supportsCredentialSets: true,
},
{
id: 'outlookCredential',
@@ -115,7 +114,6 @@ export const WebhookBlock: BlockConfig = {
placeholder: 'Select Microsoft account',
condition: { field: 'webhookProvider', value: 'outlook' },
required: true,
- supportsCredentialSets: true,
},
{
id: 'webhookConfig',
diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts
index 2133bad90d..6fa64cdc31 100644
--- a/apps/sim/components/emails/invitations/index.ts
+++ b/apps/sim/components/emails/invitations/index.ts
@@ -1,3 +1,4 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
+export { PollingGroupInvitationEmail } from './polling-group-invitation-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
diff --git a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
new file mode 100644
index 0000000000..c22486a048
--- /dev/null
+++ b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
@@ -0,0 +1,55 @@
+import { Link, Text } from '@react-email/components'
+import { createLogger } from '@sim/logger'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+
+interface PollingGroupInvitationEmailProps {
+ inviterName?: string
+ organizationName?: string
+ pollingGroupName?: string
+ provider?: 'gmail' | 'outlook'
+ inviteLink?: string
+}
+
+const logger = createLogger('PollingGroupInvitationEmail')
+
+export function PollingGroupInvitationEmail({
+ inviterName = 'A team member',
+ organizationName = 'an organization',
+ pollingGroupName = 'a polling group',
+ provider = 'gmail',
+ inviteLink = '',
+}: PollingGroupInvitationEmailProps) {
+ const brand = getBrandConfig()
+ const providerName = provider === 'gmail' ? 'Gmail' : 'Outlook'
+
+ return (
+
+ Hello,
+
+ {inviterName} from {organizationName} has invited you to
+ join the polling group {pollingGroupName} on {brand.name}.
+
+
+
+ By accepting this invitation, your {providerName} account will be connected to enable email
+ polling for automated workflows.
+
+
+
+ Accept Invitation
+
+
+ {/* Divider */}
+
+
+
+ This invitation expires in 7 days. If you weren't expecting this email, you can safely
+ ignore it.
+
+
+ )
+}
+
+export default PollingGroupInvitationEmail
diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts
index bd18eeedca..b48272c535 100644
--- a/apps/sim/components/emails/render.ts
+++ b/apps/sim/components/emails/render.ts
@@ -12,6 +12,7 @@ import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/e
import {
BatchInvitationEmail,
InvitationEmail,
+ PollingGroupInvitationEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
@@ -184,6 +185,24 @@ export async function renderWorkspaceInvitationEmail(
)
}
+export async function renderPollingGroupInvitationEmail(params: {
+ inviterName: string
+ organizationName: string
+ pollingGroupName: string
+ provider: 'gmail' | 'outlook'
+ inviteLink: string
+}): Promise {
+ return await render(
+ PollingGroupInvitationEmail({
+ inviterName: params.inviterName,
+ organizationName: params.organizationName,
+ pollingGroupName: params.pollingGroupName,
+ provider: params.provider,
+ inviteLink: params.inviteLink,
+ })
+ )
+}
+
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts
index 26f451270b..bf8b9197b5 100644
--- a/apps/sim/components/emails/subjects.ts
+++ b/apps/sim/components/emails/subjects.ts
@@ -8,6 +8,7 @@ export type EmailSubjectType =
| 'reset-password'
| 'invitation'
| 'batch-invitation'
+ | 'polling-group-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
@@ -38,6 +39,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
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 ${brandName}`
+ case 'polling-group-invitation':
+ return `You've been invited to join an email polling group on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index 9855fc6b4f..5019edddc0 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -227,3 +227,24 @@ export function useRemoveCredentialSetMember() {
},
})
}
+
+export function useLeaveCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (credentialSetId: string) => {
+ const response = await fetch(
+ `/api/credential-sets/memberships?credentialSetId=${credentialSetId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to leave credential set')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ },
+ })
+}
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 3922c4f0b5..d72fe79e2d 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -55,6 +55,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
+import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
@@ -188,6 +189,53 @@ export const auth = betterAuth({
})
.where(eq(schema.account.id, existing.id))
+ // Sync webhooks for credential sets after reconnecting
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const userMemberships = await db
+ .select({
+ credentialSetId: schema.credentialSetMember.credentialSetId,
+ providerId: schema.credentialSet.providerId,
+ type: schema.credentialSet.type,
+ })
+ .from(schema.credentialSetMember)
+ .innerJoin(
+ schema.credentialSet,
+ eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
+ )
+ .where(
+ and(
+ eq(schema.credentialSetMember.userId, account.userId),
+ eq(schema.credentialSetMember.status, 'active')
+ )
+ )
+
+ for (const membership of userMemberships) {
+ const matchesProvider =
+ membership.type === 'all' || membership.providerId === account.providerId
+
+ if (matchesProvider) {
+ try {
+ await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
+ logger.info(
+ '[account.create.before] Synced webhooks after credential reconnect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ }
+ )
+ } catch (error) {
+ logger.error(
+ '[account.create.before] Failed to sync webhooks after credential reconnect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ error,
+ }
+ )
+ }
+ }
+ }
+
return false
}
@@ -235,6 +283,50 @@ export const auth = betterAuth({
await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id))
}
}
+
+ // Sync webhooks for credential sets after connecting a new credential
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const userMemberships = await db
+ .select({
+ credentialSetId: schema.credentialSetMember.credentialSetId,
+ providerId: schema.credentialSet.providerId,
+ type: schema.credentialSet.type,
+ })
+ .from(schema.credentialSetMember)
+ .innerJoin(
+ schema.credentialSet,
+ eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
+ )
+ .where(
+ and(
+ eq(schema.credentialSetMember.userId, account.userId),
+ eq(schema.credentialSetMember.status, 'active')
+ )
+ )
+
+ for (const membership of userMemberships) {
+ const matchesProvider =
+ membership.type === 'all' || membership.providerId === account.providerId
+
+ if (matchesProvider) {
+ try {
+ await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
+ logger.info('[account.create.after] Synced webhooks after credential connect', {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ })
+ } catch (error) {
+ logger.error(
+ '[account.create.after] Failed to sync webhooks after credential connect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ error,
+ }
+ )
+ }
+ }
+ }
},
},
},
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index 69fcf1eba7..78f5487b2d 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
+import type { DbOrTx } from '@/lib/db/types'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookUtils')
@@ -2436,6 +2437,7 @@ export async function syncWebhooksForCredentialSet(params: {
oauthProviderId: string
providerConfig: Record
requestId: string
+ tx?: DbOrTx
}): Promise {
const {
workflowId,
@@ -2446,8 +2448,11 @@ export async function syncWebhooksForCredentialSet(params: {
oauthProviderId,
providerConfig,
requestId,
+ tx,
} = params
+ const dbCtx = tx ?? db
+
const syncLogger = createLogger('CredentialSetWebhookSync')
syncLogger.info(
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
@@ -2476,7 +2481,7 @@ export async function syncWebhooksForCredentialSet(params: {
)
// Get existing webhooks for this workflow+block that belong to this credential set
- const existingWebhooks = await db
+ const existingWebhooks = await dbCtx
.select()
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
@@ -2519,6 +2524,7 @@ export async function syncWebhooksForCredentialSet(params: {
const updatedConfig = {
...providerConfig,
+ basePath, // Store basePath for reliable reconstruction during membership sync
credentialId: cred.credentialId,
credentialSetId: credentialSetId,
// Preserve state fields from existing config
@@ -2529,7 +2535,7 @@ export async function syncWebhooksForCredentialSet(params: {
userId: cred.userId,
}
- await db
+ await dbCtx
.update(webhook)
.set({
providerConfig: updatedConfig,
@@ -2555,18 +2561,20 @@ export async function syncWebhooksForCredentialSet(params: {
const newConfig = {
...providerConfig,
+ basePath, // Store basePath for reliable reconstruction during membership sync
credentialId: cred.credentialId,
credentialSetId: credentialSetId,
userId: cred.userId,
}
- await db.insert(webhook).values({
+ await dbCtx.insert(webhook).values({
id: webhookId,
workflowId,
blockId,
path: webhookPath,
provider,
providerConfig: newConfig,
+ credentialSetId, // Indexed column for efficient credential set queries
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -2588,7 +2596,7 @@ export async function syncWebhooksForCredentialSet(params: {
// Delete webhooks for credentials no longer in the set
for (const [credentialId, existingWebhook] of existingByCredentialId) {
if (!credentialIdsInSet.has(credentialId)) {
- await db.delete(webhook).where(eq(webhook.id, existingWebhook.id))
+ await dbCtx.delete(webhook).where(eq(webhook.id, existingWebhook.id))
result.deleted++
syncLogger.debug(
@@ -2612,21 +2620,20 @@ export async function syncWebhooksForCredentialSet(params: {
*/
export async function syncAllWebhooksForCredentialSet(
credentialSetId: string,
- requestId: string
+ requestId: string,
+ tx?: DbOrTx
): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> {
+ const dbCtx = tx ?? db
const syncLogger = createLogger('CredentialSetMembershipSync')
syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`)
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
- // Find all webhooks that use this credential set
- const allWebhooks = await db.select().from(webhook)
-
- // Filter to webhooks with this credentialSetId in their providerConfig
- const webhooksForSet = allWebhooks.filter((wh) => {
- const config = wh.providerConfig as Record
- return config?.credentialSetId === credentialSetId
- })
+ // Find all webhooks that use this credential set using the indexed column
+ const webhooksForSet = await dbCtx
+ .select()
+ .from(webhook)
+ .where(eq(webhook.credentialSetId, credentialSetId))
if (webhooksForSet.length === 0) {
syncLogger.info(`[${requestId}] No webhooks found using credential set ${credentialSetId}`)
@@ -2660,9 +2667,9 @@ export async function syncAllWebhooksForCredentialSet(
const config = representativeWebhook.providerConfig as Record
const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider)
- const { credentialId: _cId, userId: _uId, ...baseConfig } = config
- const pathParts = representativeWebhook.path.split('-')
- const basePath = `${pathParts[0]}-${pathParts[1]}`
+ const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config
+ // Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks)
+ const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path
try {
const result = await syncWebhooksForCredentialSet({
@@ -2674,6 +2681,7 @@ export async function syncAllWebhooksForCredentialSet(
oauthProviderId,
providerConfig: baseConfig,
requestId,
+ tx: dbCtx,
})
workflowsUpdated++
diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts
index 96481c586d..34bc5969f6 100644
--- a/apps/sim/lib/workflows/persistence/utils.ts
+++ b/apps/sim/lib/workflows/persistence/utils.ts
@@ -430,6 +430,7 @@ export async function saveWorkflowToNormalizedTables(
path: wh.path,
provider: wh.provider,
providerConfig: wh.providerConfig,
+ credentialSetId: wh.credentialSetId,
isActive: wh.isActive,
createdAt: wh.createdAt,
updatedAt: new Date(),
diff --git a/apps/sim/triggers/airtable/webhook.ts b/apps/sim/triggers/airtable/webhook.ts
index 8dca58c329..45c8f6cf3a 100644
--- a/apps/sim/triggers/airtable/webhook.ts
+++ b/apps/sim/triggers/airtable/webhook.ts
@@ -20,7 +20,6 @@ export const airtableWebhookTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
},
{
id: 'baseId',
diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts
index 5262f5c6d6..3a3386e9cc 100644
--- a/apps/sim/triggers/jira/utils.ts
+++ b/apps/sim/triggers/jira/utils.ts
@@ -44,7 +44,6 @@ export const jiraWebhookSubBlocks: SubBlockConfig[] = [
placeholder: 'Select Jira account',
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
},
{
id: 'webhookUrlDisplay',
diff --git a/apps/sim/triggers/microsoftteams/chat_webhook.ts b/apps/sim/triggers/microsoftteams/chat_webhook.ts
index 543a493efc..340b2a567b 100644
--- a/apps/sim/triggers/microsoftteams/chat_webhook.ts
+++ b/apps/sim/triggers/microsoftteams/chat_webhook.ts
@@ -41,7 +41,6 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts
index 1802bfdf95..68657d588b 100644
--- a/apps/sim/triggers/webflow/collection_item_changed.ts
+++ b/apps/sim/triggers/webflow/collection_item_changed.ts
@@ -20,7 +20,6 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts
index 46a4da9c69..a4eaa831a8 100644
--- a/apps/sim/triggers/webflow/collection_item_created.ts
+++ b/apps/sim/triggers/webflow/collection_item_created.ts
@@ -33,7 +33,6 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts
index 535030e030..6b50f63986 100644
--- a/apps/sim/triggers/webflow/collection_item_deleted.ts
+++ b/apps/sim/triggers/webflow/collection_item_deleted.ts
@@ -20,7 +20,6 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts
index fc84078ff9..e0a0725e8c 100644
--- a/apps/sim/triggers/webflow/form_submission.ts
+++ b/apps/sim/triggers/webflow/form_submission.ts
@@ -20,7 +20,6 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
requiredScopes: [],
required: true,
mode: 'trigger',
- supportsCredentialSets: true,
},
{
id: 'siteId',
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 68173a7957..83749b3d0c 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -527,6 +527,9 @@ export const webhook = pgTable(
isActive: boolean('is_active').notNull().default(true),
failedCount: integer('failed_count').default(0), // Track consecutive failures
lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed
+ credentialSetId: text('credential_set_id').references(() => credentialSet.id, {
+ onDelete: 'set null',
+ }), // For credential set webhooks - enables efficient queries
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
@@ -539,6 +542,8 @@ export const webhook = pgTable(
table.workflowId,
table.blockId
),
+ // Optimize queries for credential set webhooks
+ credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId),
}
}
)
@@ -1811,6 +1816,7 @@ export const credentialSet = pgTable(
export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [
'active',
'pending',
+ 'revoked',
])
export const credentialSetMember = pgTable(
From 223d1e8523245d4b895a0cf6a75d7f39c83ea1d1 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 11:25:50 -0800
Subject: [PATCH 05/21] invite flow
---
apps/sim/app/(auth)/signup/signup-form.tsx | 8 +-
.../credential-sets/invite/[token]/route.ts | 20 ++-
.../app/credential-account/[token]/page.tsx | 144 +++++++++++++++---
.../credential-sets/credential-sets.tsx | 25 ++-
4 files changed, 171 insertions(+), 26 deletions(-)
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 0c08283b37..108e964909 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -109,11 +109,15 @@ function SignupFormContent({
setEmail(emailParam)
}
- const redirectParam = searchParams.get('redirect')
+ // Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
+ const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
if (redirectParam) {
setRedirectUrl(redirectParam)
- if (redirectParam.startsWith('/invite/')) {
+ if (
+ redirectParam.startsWith('/invite/') ||
+ redirectParam.startsWith('/credential-account/')
+ ) {
setIsInviteFlow(true)
}
}
diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
index de9ad6df40..c42fbecda5 100644
--- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts
+++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
credentialSetName: credentialSet.name,
+ providerId: credentialSet.providerId,
organizationId: credentialSet.organizationId,
organizationName: organization.name,
})
@@ -54,6 +55,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
invitation: {
credentialSetName: invitation.credentialSetName,
organizationName: invitation.organizationName,
+ providerId: invitation.providerId,
email: invitation.email,
},
})
@@ -68,16 +70,27 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
}
try {
- const [invitation] = await db
- .select()
+ const [invitationData] = await db
+ .select({
+ id: credentialSetInvitation.id,
+ credentialSetId: credentialSetInvitation.credentialSetId,
+ email: credentialSetInvitation.email,
+ status: credentialSetInvitation.status,
+ expiresAt: credentialSetInvitation.expiresAt,
+ invitedBy: credentialSetInvitation.invitedBy,
+ providerId: credentialSet.providerId,
+ })
.from(credentialSetInvitation)
+ .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
.where(eq(credentialSetInvitation.token, token))
.limit(1)
- if (!invitation) {
+ if (!invitationData) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
+ const invitation = invitationData
+
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
}
@@ -174,6 +187,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,
+ providerId: invitation.providerId,
})
} catch (error) {
logger.error('Error accepting invitation', error)
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
index d363f30013..45172c1ffb 100644
--- a/apps/sim/app/credential-account/[token]/page.tsx
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -1,17 +1,33 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
-import { AlertCircle, CheckCircle2, Loader2, Shield } from 'lucide-react'
+import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
-import { useSession } from '@/lib/auth/auth-client'
+import { GmailIcon, OutlookIcon } from '@/components/icons'
+import { client, useSession } from '@/lib/auth/auth-client'
interface InvitationInfo {
credentialSetName: string
organizationName: string
+ providerId: string | null
email: string | null
}
+type AcceptedState = 'connecting' | 'already-connected'
+
+/**
+ * Maps credential set provider IDs to OAuth provider IDs
+ * The credential set stores 'gmail' but the OAuth provider is 'google-email'
+ */
+function getOAuthProviderId(credentialSetProviderId: string): string {
+ if (credentialSetProviderId === 'gmail') {
+ return 'google-email'
+ }
+ // outlook is the same in both
+ return credentialSetProviderId
+}
+
export default function CredentialAccountInvitePage() {
const params = useParams()
const router = useRouter()
@@ -23,7 +39,7 @@ export default function CredentialAccountInvitePage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [accepting, setAccepting] = useState(false)
- const [accepted, setAccepted] = useState(false)
+ const [acceptedState, setAcceptedState] = useState(null)
useEffect(() => {
async function fetchInvitation() {
@@ -48,7 +64,9 @@ export default function CredentialAccountInvitePage() {
const handleAccept = useCallback(async () => {
if (!session?.user?.id) {
- router.push(`/login?callbackUrl=${encodeURIComponent(`/credential-account/${token}`)}`)
+ // Include invite_flow=true so the login page preserves callbackUrl when linking to signup
+ const callbackUrl = encodeURIComponent(`/credential-account/${token}`)
+ router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`)
return
}
@@ -64,13 +82,63 @@ export default function CredentialAccountInvitePage() {
return
}
- setAccepted(true)
+ const data = await res.json()
+ const credentialSetProviderId = data.providerId || invitation?.providerId
+
+ // Check if user already has this provider connected
+ let isAlreadyConnected = false
+ if (credentialSetProviderId) {
+ const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
+ try {
+ const connectionsRes = await fetch('/api/auth/oauth/connections')
+ if (connectionsRes.ok) {
+ const connectionsData = await connectionsRes.json()
+ const connections = connectionsData.connections || []
+ isAlreadyConnected = connections.some(
+ (conn: { provider: string; accounts?: { id: string }[] }) =>
+ conn.provider === oauthProviderId && conn.accounts && conn.accounts.length > 0
+ )
+ }
+ } catch {
+ // If we can't check connections, proceed with OAuth flow
+ }
+ }
+
+ if (isAlreadyConnected) {
+ // Already connected - redirect to workspace
+ setAcceptedState('already-connected')
+ setTimeout(() => {
+ router.push('/workspace')
+ }, 2000)
+ } else if (credentialSetProviderId === 'gmail' || credentialSetProviderId === 'outlook') {
+ // Not connected - start OAuth flow
+ setAcceptedState('connecting')
+
+ // Small delay to show success message before redirect
+ setTimeout(async () => {
+ try {
+ const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
+ await client.oauth2.link({
+ providerId: oauthProviderId,
+ callbackURL: `${window.location.origin}/workspace`,
+ })
+ } catch (oauthError) {
+ // OAuth redirect will happen, this catch is for any pre-redirect errors
+ console.error('OAuth initiation error:', oauthError)
+ // If OAuth fails, redirect to workspace where they can connect manually
+ router.push('/workspace')
+ }
+ }, 1500)
+ } else {
+ // No provider specified - just redirect to workspace
+ router.push('/workspace')
+ }
} catch {
setError('Failed to accept invitation')
} finally {
setAccepting(false)
}
- }, [session?.user?.id, token, router])
+ }, [session?.user?.id, token, router, invitation?.providerId])
if (loading || sessionLoading) {
return (
@@ -94,19 +162,49 @@ export default function CredentialAccountInvitePage() {
)
}
- if (accepted) {
+ const ProviderIcon =
+ invitation?.providerId === 'outlook'
+ ? OutlookIcon
+ : invitation?.providerId === 'gmail'
+ ? GmailIcon
+ : Mail
+ const providerName =
+ invitation?.providerId === 'outlook'
+ ? 'Outlook'
+ : invitation?.providerId === 'gmail'
+ ? 'Gmail'
+ : 'email'
+
+ if (acceptedState === 'already-connected') {
return (
-
Welcome!
+
You're all set!
+
+ You've joined {invitation?.credentialSetName}. Your {providerName} account is already
+ connected.
+
+
Redirecting to workspace...
+
+
+
+ )
+ }
+
+ if (acceptedState === 'connecting') {
+ return (
+
+
+
+
+ Connecting to {providerName}...
+
- You've successfully joined {invitation?.credentialSetName}. Connect your OAuth
- credentials in Settings → Integrations.
+ You've joined {invitation?.credentialSetName}. You'll be redirected to connect your{' '}
+ {providerName} account.
-
router.push('/workspace')}>
- Go to Dashboard
-
+
)
@@ -116,8 +214,10 @@ export default function CredentialAccountInvitePage() {
-
-
Join Credential Set
+
+
+ Join Email Polling Group
+
You've been invited to join{' '}
@@ -125,6 +225,11 @@ export default function CredentialAccountInvitePage() {
{' '}
by {invitation?.organizationName}
+ {invitation?.providerId && (
+
+ You'll be asked to connect your {providerName} account after accepting.
+
+ )}
@@ -141,7 +246,10 @@ export default function CredentialAccountInvitePage() {
Joining...
>
) : (
- 'Accept Invitation'
+ <>
+
+ Accept & Connect {providerName}
+ >
)}
>
@@ -158,8 +266,8 @@ export default function CredentialAccountInvitePage() {
- By joining, you agree to share your OAuth credentials with this credential set for use in
- automated workflows.
+ By joining, you agree to share your {providerName} credentials with this polling group for
+ use in automated email workflows.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index fd847cfabf..cda1b5df15 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -80,6 +80,7 @@ export function CredentialSets() {
const [newSetName, setNewSetName] = useState('')
const [newSetDescription, setNewSetDescription] = useState('')
const [newSetProvider, setNewSetProvider] = useState<'gmail' | 'outlook'>('gmail')
+ const [createError, setCreateError] = useState(null)
const [inviteEmails, setInviteEmails] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [leavingMembership, setLeavingMembership] = useState<{
@@ -204,6 +205,7 @@ export function CredentialSets() {
const handleCreateCredentialSet = useCallback(async () => {
if (!newSetName.trim() || !activeOrganization?.id) return
+ setCreateError(null)
try {
await createCredentialSet.mutateAsync({
organizationId: activeOrganization.id,
@@ -218,6 +220,11 @@ export function CredentialSets() {
setNewSetProvider('gmail')
} catch (error) {
logger.error('Failed to create polling group', error)
+ if (error instanceof Error) {
+ setCreateError(error.message)
+ } else {
+ setCreateError('Failed to create polling group')
+ }
}
}, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet])
@@ -247,6 +254,14 @@ export function CredentialSets() {
}
}, [selectedSetId, inviteEmails, createInvitation])
+ const handleCloseCreateModal = useCallback(() => {
+ setShowCreateModal(false)
+ setNewSetName('')
+ setNewSetDescription('')
+ setNewSetProvider('gmail')
+ setCreateError(null)
+ }, [])
+
const handleCloseInviteModal = useCallback(() => {
setShowInviteModal(false)
setInviteEmails('')
@@ -573,7 +588,7 @@ export function CredentialSets() {
)}
-
+
Create Polling Group
@@ -582,7 +597,10 @@ export function CredentialSets() {
Name
setNewSetName(e.target.value)}
+ onChange={(e) => {
+ setNewSetName(e.target.value)
+ if (createError) setCreateError(null)
+ }}
placeholder='e.g., Marketing Team'
/>
@@ -619,10 +637,11 @@ export function CredentialSets() {
account for email polling
+ {createError && {createError}
}
- setShowCreateModal(false)}>
+
Cancel
Date: Tue, 6 Jan 2026 12:15:04 -0800
Subject: [PATCH 06/21] simplify code
---
.../app/api/auth/oauth/disconnect/route.ts | 7 ++--
.../api/credential-sets/[id]/invite/route.ts | 2 +-
.../api/credential-sets/[id]/members/route.ts | 37 ++++++-------------
.../sim/app/api/credential-sets/[id]/route.ts | 1 -
apps/sim/app/api/credential-sets/route.ts | 25 ++++---------
.../app/credential-account/[token]/page.tsx | 36 ++++++------------
.../credential-selector.tsx | 8 ++--
.../credential-sets/credential-sets.tsx | 27 +++++---------
.../polling-group-invitation-email.tsx | 9 ++---
apps/sim/components/emails/render.ts | 2 +-
apps/sim/hooks/queries/credential-sets.ts | 4 --
apps/sim/lib/auth/auth.ts | 12 +-----
apps/sim/lib/credential-sets/providers.ts | 27 ++++++++++++++
packages/db/schema.ts | 7 +---
14 files changed, 85 insertions(+), 119 deletions(-)
create mode 100644 apps/sim/lib/credential-sets/providers.ts
diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts
index 2cbb7ad60b..be645aa732 100644
--- a/apps/sim/app/api/auth/oauth/disconnect/route.ts
+++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts
@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
id: credentialSetMember.id,
credentialSetId: credentialSetMember.credentialSetId,
providerId: credentialSet.providerId,
- type: credentialSet.type,
})
.from(credentialSetMember)
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
@@ -94,12 +93,12 @@ export async function POST(request: NextRequest) {
)
for (const membership of userMemberships) {
- // Only sync if the credential set matches this provider or is 'all' type
+ // Only sync if the credential set matches this provider
+ // Credential sets store OAuth provider IDs like 'google-email' or 'outlook'
const matchesProvider =
- membership.type === 'all' ||
membership.providerId === provider ||
membership.providerId === providerId ||
- (membership.providerId && provider.startsWith(membership.providerId))
+ membership.providerId?.startsWith(`${provider}-`)
if (matchesProvider) {
try {
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
index 7c911b4075..239de4456e 100644
--- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
@@ -121,7 +121,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
.where(eq(organization.id, result.set.organizationId))
.limit(1)
- const provider = (result.set.providerId as 'gmail' | 'outlook') || 'gmail'
+ const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email'
const emailHtml = await renderPollingGroupInvitationEmail({
inviterName: inviter?.name || 'A team member',
organizationName: org?.name || 'your organization',
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
index bc62e29873..f7cc55e8d0 100644
--- a/apps/sim/app/api/credential-sets/[id]/members/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -13,7 +13,6 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
- type: credentialSet.type,
providerId: credentialSet.providerId,
})
.from(credentialSet)
@@ -62,34 +61,22 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
.leftJoin(user, eq(credentialSetMember.userId, user.id))
.where(eq(credentialSetMember.credentialSetId, id))
- // Get credentials for all active members
+ // Get credentials for all active members filtered by the polling group's provider
const activeMembers = members.filter((m) => m.status === 'active')
const memberUserIds = activeMembers.map((m) => m.userId)
let credentials: { userId: string; providerId: string; accountId: string }[] = []
- if (memberUserIds.length > 0) {
- // If credential set is for a specific provider, filter by that provider
- if (result.set.type === 'specific' && result.set.providerId) {
- credentials = await db
- .select({
- userId: account.userId,
- providerId: account.providerId,
- accountId: account.accountId,
- })
- .from(account)
- .where(
- and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId))
- )
- } else {
- credentials = await db
- .select({
- userId: account.userId,
- providerId: account.providerId,
- accountId: account.accountId,
- })
- .from(account)
- .where(inArray(account.userId, memberUserIds))
- }
+ if (memberUserIds.length > 0 && result.set.providerId) {
+ credentials = await db
+ .select({
+ userId: account.userId,
+ providerId: account.providerId,
+ accountId: account.accountId,
+ })
+ .from(account)
+ .where(
+ and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId))
+ )
}
// Group credentials by userId
diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts
index 9eee99e135..730966e19d 100644
--- a/apps/sim/app/api/credential-sets/[id]/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/route.ts
@@ -20,7 +20,6 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
organizationId: credentialSet.organizationId,
name: credentialSet.name,
description: credentialSet.description,
- type: credentialSet.type,
providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts
index 4c73f8600e..19f6521b91 100644
--- a/apps/sim/app/api/credential-sets/route.ts
+++ b/apps/sim/app/api/credential-sets/route.ts
@@ -8,18 +8,12 @@ import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialSets')
-const createCredentialSetSchema = z
- .object({
- organizationId: z.string().min(1),
- name: z.string().trim().min(1).max(100),
- description: z.string().max(500).optional(),
- type: z.enum(['all', 'specific']).default('all'),
- providerId: z.string().min(1).optional(),
- })
- .refine((data) => data.type !== 'specific' || data.providerId, {
- message: 'providerId is required when type is specific',
- path: ['providerId'],
- })
+const createCredentialSetSchema = z.object({
+ organizationId: z.string().min(1),
+ name: z.string().trim().min(1).max(100),
+ description: z.string().max(500).optional(),
+ providerId: z.enum(['google-email', 'outlook']),
+})
export async function GET(req: Request) {
const session = await getSession()
@@ -50,7 +44,6 @@ export async function GET(req: Request) {
id: credentialSet.id,
name: credentialSet.name,
description: credentialSet.description,
- type: credentialSet.type,
providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
@@ -94,8 +87,7 @@ export async function POST(req: Request) {
try {
const body = await req.json()
- const { organizationId, name, description, type, providerId } =
- createCredentialSetSchema.parse(body)
+ const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)
const membership = await db
.select({ id: member.id, role: member.role })
@@ -139,8 +131,7 @@ export async function POST(req: Request) {
organizationId,
name,
description: description || null,
- type,
- providerId: type === 'specific' ? providerId : null,
+ providerId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
index 45172c1ffb..2abed9dc0c 100644
--- a/apps/sim/app/credential-account/[token]/page.tsx
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -6,6 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { client, useSession } from '@/lib/auth/auth-client'
+import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
interface InvitationInfo {
credentialSetName: string
@@ -16,18 +17,6 @@ interface InvitationInfo {
type AcceptedState = 'connecting' | 'already-connected'
-/**
- * Maps credential set provider IDs to OAuth provider IDs
- * The credential set stores 'gmail' but the OAuth provider is 'google-email'
- */
-function getOAuthProviderId(credentialSetProviderId: string): string {
- if (credentialSetProviderId === 'gmail') {
- return 'google-email'
- }
- // outlook is the same in both
- return credentialSetProviderId
-}
-
export default function CredentialAccountInvitePage() {
const params = useParams()
const router = useRouter()
@@ -87,8 +76,7 @@ export default function CredentialAccountInvitePage() {
// Check if user already has this provider connected
let isAlreadyConnected = false
- if (credentialSetProviderId) {
- const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
+ if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
try {
const connectionsRes = await fetch('/api/auth/oauth/connections')
if (connectionsRes.ok) {
@@ -96,7 +84,9 @@ export default function CredentialAccountInvitePage() {
const connections = connectionsData.connections || []
isAlreadyConnected = connections.some(
(conn: { provider: string; accounts?: { id: string }[] }) =>
- conn.provider === oauthProviderId && conn.accounts && conn.accounts.length > 0
+ conn.provider === credentialSetProviderId &&
+ conn.accounts &&
+ conn.accounts.length > 0
)
}
} catch {
@@ -110,16 +100,15 @@ export default function CredentialAccountInvitePage() {
setTimeout(() => {
router.push('/workspace')
}, 2000)
- } else if (credentialSetProviderId === 'gmail' || credentialSetProviderId === 'outlook') {
+ } else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
// Not connected - start OAuth flow
setAcceptedState('connecting')
// Small delay to show success message before redirect
setTimeout(async () => {
try {
- const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
await client.oauth2.link({
- providerId: oauthProviderId,
+ providerId: credentialSetProviderId,
callbackURL: `${window.location.origin}/workspace`,
})
} catch (oauthError) {
@@ -165,15 +154,12 @@ export default function CredentialAccountInvitePage() {
const ProviderIcon =
invitation?.providerId === 'outlook'
? OutlookIcon
- : invitation?.providerId === 'gmail'
+ : invitation?.providerId === 'google-email'
? GmailIcon
: Mail
- const providerName =
- invitation?.providerId === 'outlook'
- ? 'Outlook'
- : invitation?.providerId === 'gmail'
- ? 'Gmail'
- : 'email'
+ const providerName = invitation?.providerId
+ ? getProviderDisplayName(invitation.providerId)
+ : 'email'
if (acceptedState === 'already-connected') {
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index ca82ee94a9..b045f14047 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
+import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -215,9 +216,10 @@ export function CredentialSelector({
}, [])
const { comboboxOptions, comboboxGroups } = useMemo(() => {
- const filteredCredentialSets = credentialSets.filter(
- (cs) => cs.type === 'all' || cs.providerId === effectiveProviderId
- )
+ const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
+ const filteredCredentialSets = pollingProviderId
+ ? credentialSets.filter((cs) => cs.providerId === pollingProviderId)
+ : []
if (canUseCredentialSets && filteredCredentialSets.length > 0) {
const groups = []
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index cda1b5df15..c1475362ee 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -21,10 +21,10 @@ import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
+import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
import { getUserRole } from '@/lib/workspaces/organization'
import {
type CredentialSet,
- type CredentialSetType,
useAcceptCredentialSetInvitation,
useCreateCredentialSet,
useCreateCredentialSetInvitation,
@@ -79,7 +79,7 @@ export function CredentialSets() {
const [viewingSet, setViewingSet] = useState(null)
const [newSetName, setNewSetName] = useState('')
const [newSetDescription, setNewSetDescription] = useState('')
- const [newSetProvider, setNewSetProvider] = useState<'gmail' | 'outlook'>('gmail')
+ const [newSetProvider, setNewSetProvider] = useState('google-email')
const [createError, setCreateError] = useState(null)
const [inviteEmails, setInviteEmails] = useState('')
const [isDragging, setIsDragging] = useState(false)
@@ -92,12 +92,6 @@ export function CredentialSets() {
const removeMember = useRemoveCredentialSetMember()
const leaveCredentialSet = useLeaveCredentialSet()
- const getProviderName = useCallback((providerId: string) => {
- if (providerId === 'gmail') return 'Gmail'
- if (providerId === 'outlook') return 'Outlook'
- return providerId
- }, [])
-
const extractEmailsFromText = useCallback((text: string): string[] => {
// Match email patterns in text
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
@@ -211,13 +205,12 @@ export function CredentialSets() {
organizationId: activeOrganization.id,
name: newSetName.trim(),
description: newSetDescription.trim() || undefined,
- type: 'specific' as CredentialSetType,
providerId: newSetProvider,
})
setShowCreateModal(false)
setNewSetName('')
setNewSetDescription('')
- setNewSetProvider('gmail')
+ setNewSetProvider('google-email')
} catch (error) {
logger.error('Failed to create polling group', error)
if (error instanceof Error) {
@@ -258,7 +251,7 @@ export function CredentialSets() {
setShowCreateModal(false)
setNewSetName('')
setNewSetDescription('')
- setNewSetProvider('gmail')
+ setNewSetProvider('google-email')
setCreateError(null)
}, [])
@@ -292,7 +285,7 @@ export function CredentialSets() {
{viewingSet.name}
- {getProviderName(viewingSet.providerId || '')}
+ {getProviderDisplayName(viewingSet.providerId || '')}
@@ -569,7 +562,7 @@ export function CredentialSets() {
{set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
- {set.providerId && <> · {getProviderName(set.providerId)}>}
+ {set.providerId && <> · {getProviderDisplayName(set.providerId)}>}
Email Provider
setNewSetProvider('gmail')}
+ variant={newSetProvider === 'google-email' ? 'active' : 'default'}
+ onClick={() => setNewSetProvider('google-email')}
className='flex-1'
>
@@ -633,8 +626,8 @@ export function CredentialSets() {
- Members will connect their {newSetProvider === 'gmail' ? 'Gmail' : 'Outlook'}{' '}
- account for email polling
+ Members will connect their {getProviderDisplayName(newSetProvider)} account for
+ email polling
{createError && {createError}
}
diff --git a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
index c22486a048..e87436a154 100644
--- a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
+++ b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
@@ -1,5 +1,4 @@
import { Link, Text } from '@react-email/components'
-import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
@@ -8,21 +7,19 @@ interface PollingGroupInvitationEmailProps {
inviterName?: string
organizationName?: string
pollingGroupName?: string
- provider?: 'gmail' | 'outlook'
+ provider?: 'google-email' | 'outlook'
inviteLink?: string
}
-const logger = createLogger('PollingGroupInvitationEmail')
-
export function PollingGroupInvitationEmail({
inviterName = 'A team member',
organizationName = 'an organization',
pollingGroupName = 'a polling group',
- provider = 'gmail',
+ provider = 'google-email',
inviteLink = '',
}: PollingGroupInvitationEmailProps) {
const brand = getBrandConfig()
- const providerName = provider === 'gmail' ? 'Gmail' : 'Outlook'
+ const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
return (
diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts
index b48272c535..90522246aa 100644
--- a/apps/sim/components/emails/render.ts
+++ b/apps/sim/components/emails/render.ts
@@ -189,7 +189,7 @@ export async function renderPollingGroupInvitationEmail(params: {
inviterName: string
organizationName: string
pollingGroupName: string
- provider: 'gmail' | 'outlook'
+ provider: 'google-email' | 'outlook'
inviteLink: string
}): Promise {
return await render(
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index 5019edddc0..77e83d2435 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -1,13 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'
-export type CredentialSetType = 'all' | 'specific'
-
export interface CredentialSet {
id: string
name: string
description: string | null
- type: CredentialSetType
providerId: string | null
createdBy: string
createdAt: string
@@ -126,7 +123,6 @@ export interface CreateCredentialSetData {
organizationId: string
name: string
description?: string
- type?: CredentialSetType
providerId?: string
}
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index d72fe79e2d..926d833eb6 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -195,7 +195,6 @@ export const auth = betterAuth({
.select({
credentialSetId: schema.credentialSetMember.credentialSetId,
providerId: schema.credentialSet.providerId,
- type: schema.credentialSet.type,
})
.from(schema.credentialSetMember)
.innerJoin(
@@ -210,10 +209,7 @@ export const auth = betterAuth({
)
for (const membership of userMemberships) {
- const matchesProvider =
- membership.type === 'all' || membership.providerId === account.providerId
-
- if (matchesProvider) {
+ if (membership.providerId === account.providerId) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info(
@@ -290,7 +286,6 @@ export const auth = betterAuth({
.select({
credentialSetId: schema.credentialSetMember.credentialSetId,
providerId: schema.credentialSet.providerId,
- type: schema.credentialSet.type,
})
.from(schema.credentialSetMember)
.innerJoin(
@@ -305,10 +300,7 @@ export const auth = betterAuth({
)
for (const membership of userMemberships) {
- const matchesProvider =
- membership.type === 'all' || membership.providerId === account.providerId
-
- if (matchesProvider) {
+ if (membership.providerId === account.providerId) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info('[account.create.after] Synced webhooks after credential connect', {
diff --git a/apps/sim/lib/credential-sets/providers.ts b/apps/sim/lib/credential-sets/providers.ts
new file mode 100644
index 0000000000..4de42ffb78
--- /dev/null
+++ b/apps/sim/lib/credential-sets/providers.ts
@@ -0,0 +1,27 @@
+export type PollingProvider = 'google-email' | 'outlook'
+
+export const POLLING_PROVIDERS: Record = {
+ 'google-email': { displayName: 'Gmail' },
+ outlook: { displayName: 'Outlook' },
+}
+
+export function getProviderDisplayName(providerId: string): string {
+ if (providerId === 'google-email') return 'Gmail'
+ if (providerId === 'outlook') return 'Outlook'
+ return providerId
+}
+
+export function isPollingProvider(provider: string): provider is PollingProvider {
+ return provider === 'google-email' || provider === 'outlook'
+}
+
+/**
+ * Maps an OAuth provider ID to its corresponding polling provider ID.
+ * Since credential sets now store the OAuth provider ID directly, this is primarily
+ * used in the credential selector to match OAuth providers to credential sets.
+ */
+export function getPollingProviderFromOAuth(oauthProviderId: string): PollingProvider | null {
+ if (oauthProviderId === 'google-email') return 'google-email'
+ if (oauthProviderId === 'outlook') return 'outlook'
+ return null
+}
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 83749b3d0c..c03d4e65b3 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -1783,8 +1783,6 @@ export const usageLog = pgTable(
})
)
-export const credentialSetTypeEnum = pgEnum('credential_set_type', ['all', 'specific'])
-
export const credentialSet = pgTable(
'credential_set',
{
@@ -1794,8 +1792,7 @@ export const credentialSet = pgTable(
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
- type: credentialSetTypeEnum('type').notNull().default('all'),
- providerId: text('provider_id'),
+ providerId: text('provider_id').notNull(),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
@@ -1809,7 +1806,7 @@ export const credentialSet = pgTable(
table.organizationId,
table.name
),
- typeIdx: index('credential_set_type_idx').on(table.type),
+ providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId),
})
)
From faada904a0e26ba1773be29b154f9426a17c3314 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 13:54:10 -0800
Subject: [PATCH 07/21] fix ui
---
.../credential-selector.tsx | 16 +-
.../credential-sets/credential-sets.tsx | 663 ++++++++++--------
2 files changed, 370 insertions(+), 309 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index b045f14047..73b5467061 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -217,15 +217,23 @@ export function CredentialSelector({
const { comboboxOptions, comboboxGroups } = useMemo(() => {
const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
+ // Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility
+ const matchesProvider = (csProviderId: string | null) => {
+ if (!csProviderId || !pollingProviderId) return false
+ if (csProviderId === pollingProviderId) return true
+ // Handle legacy 'gmail' mapping to 'google-email'
+ if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true
+ return false
+ }
const filteredCredentialSets = pollingProviderId
- ? credentialSets.filter((cs) => cs.providerId === pollingProviderId)
+ ? credentialSets.filter((cs) => matchesProvider(cs.providerId))
: []
if (canUseCredentialSets && filteredCredentialSets.length > 0) {
const groups = []
groups.push({
- section: 'Credential Sets',
+ section: 'Polling Groups',
items: filteredCredentialSets.map((cs) => ({
label: cs.name,
value: `${CREDENTIAL_SET.PREFIX}${cs.id}`,
@@ -239,12 +247,12 @@ export function CredentialSelector({
if (credentialItems.length > 0) {
groups.push({
- section: 'My Credentials',
+ section: 'Personal Credential',
items: credentialItems,
})
} else {
groups.push({
- section: 'My Credentials',
+ section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index c1475362ee..320e4c31f5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -2,7 +2,7 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ArrowLeft, Loader2, LogOut, Plus, Trash2, User } from 'lucide-react'
+import { ArrowLeft, Loader2, Plus, User } from 'lucide-react'
import {
Avatar,
AvatarFallback,
@@ -21,6 +21,7 @@ import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
+import { cn } from '@/lib/core/utils/cn'
import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
import { getUserRole } from '@/lib/workspaces/organization'
import {
@@ -43,9 +44,32 @@ const logger = createLogger('EmailPolling')
function CredentialSetsSkeleton() {
return (
)
}
@@ -93,10 +117,8 @@ export function CredentialSets() {
const leaveCredentialSet = useLeaveCredentialSet()
const extractEmailsFromText = useCallback((text: string): string[] => {
- // Match email patterns in text
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
const matches = text.match(emailRegex) || []
- // Deduplicate and return
return [...new Set(matches.map((e) => e.toLowerCase()))]
}, [])
@@ -201,7 +223,7 @@ export function CredentialSets() {
if (!newSetName.trim() || !activeOrganization?.id) return
setCreateError(null)
try {
- await createCredentialSet.mutateAsync({
+ const result = await createCredentialSet.mutateAsync({
organizationId: activeOrganization.id,
name: newSetName.trim(),
description: newSetDescription.trim() || undefined,
@@ -211,6 +233,12 @@ export function CredentialSets() {
setNewSetName('')
setNewSetDescription('')
setNewSetProvider('google-email')
+
+ // Open invite modal for the newly created group
+ if (result?.credentialSet?.id) {
+ setSelectedSetId(result.credentialSet.id)
+ setShowInviteModal(true)
+ }
} catch (error) {
logger.error('Failed to create polling group', error)
if (error instanceof Error) {
@@ -224,7 +252,6 @@ export function CredentialSets() {
const handleCreateInvite = useCallback(async () => {
if (!selectedSetId) return
- // Parse comma-separated or newline-separated emails
const emails = inviteEmails
.split(/[,\n]/)
.map((e) => e.trim())
@@ -261,6 +288,11 @@ export function CredentialSets() {
setSelectedSetId(null)
}, [])
+ const getProviderIcon = (providerId: string | null) => {
+ if (providerId === 'outlook') return
+ return
+ }
+
if (membershipsLoading || invitationsLoading) {
return
}
@@ -275,152 +307,157 @@ export function CredentialSets() {
const pendingMembers = members.filter((m) => m.status === 'pending')
return (
-
-
-
setViewingSet(null)} className='p-[4px]'>
-
-
-
-
- {viewingSet.name}
-
-
- {getProviderDisplayName(viewingSet.providerId || '')}
-
+ <>
+
+
+
+
setViewingSet(null)} className='h-9 w-9 p-0'>
+
+
+
+
+ {getProviderIcon(viewingSet.providerId)}
+
+
+ {viewingSet.name}
+
+ {getProviderDisplayName(viewingSet.providerId || '')}
+
+
+
+
+
{
+ setSelectedSetId(viewingSet.id)
+ setShowInviteModal(true)
+ }}
+ >
+
+ Add Members
+
-
- {membersLoading ? (
-
-
-
-
- ) : (
- <>
- {activeMembers.length > 0 && (
-
-
- Active Members ({activeMembers.length})
-
- {activeMembers.map((member) => (
-
-
-
-
-
-
-
-
-
-
- {member.userName || 'Unknown'}
-
-
- {member.userEmail}
-
+
+
+ {membersLoading ? (
+
+
+ {[1, 2].map((i) => (
+
-
handleRemoveMember(member.id)}
- disabled={removeMember.isPending}
- className='p-[6px] text-[var(--text-muted)] hover:text-[var(--text-error)]'
- >
-
-
-
- ))}
-
- )}
+ ))}
+
+ ) : (
+ <>
+ {activeMembers.length > 0 && (
+
+
+ Active Members ({activeMembers.length})
+
+ {activeMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
+ {member.userName || 'Unknown'}
+
+
+ {member.userEmail}
+
+
+
+
handleRemoveMember(member.id)}
+ disabled={removeMember.isPending}
+ >
+ Remove
+
+
+ ))}
+
+ )}
- {pendingMembers.length > 0 && (
-
-
- Pending ({pendingMembers.length})
-
- {pendingMembers.map((member) => (
-
-
-
-
-
-
-
-
-
-
- {member.userName || 'Unknown'}
-
-
- {member.userEmail}
-
+ {pendingMembers.length > 0 && (
+
+
+ Pending ({pendingMembers.length})
+ {pendingMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
+ {member.userName || 'Unknown'}
+
+
+ {member.userEmail}
+
+
+
+
Pending
+
+ ))}
-
Pending
-
- ))}
-
- )}
+ )}
- {members.length === 0 && (
-
-
No members yet
-
- Invite people to join this polling group
-
-
- )}
- >
- )}
-
-
-
{
- setSelectedSetId(viewingSet.id)
- setShowInviteModal(true)
- }}
- >
-
- Add Member
-
+ {members.length === 0 && (
+
No members yet
+ )}
+ >
+ )}
+
+
-
+
Add Members
-
-
-
Email Addresses
-
+
+
Email Addresses
+
-
+
Invitees will receive an email with a link to connect their account.
@@ -435,7 +472,7 @@ export function CredentialSets() {
disabled={createInvitation.isPending || !inviteEmails.trim()}
>
{createInvitation.isPending ? (
-
+
) : (
'Send Invites'
)}
@@ -443,149 +480,162 @@ export function CredentialSets() {
-
+ >
)
}
return (
-
- {hasNoContent && !canManageCredentialSets && (
-
-
- You're not a member of any polling groups yet.
-
-
- When someone invites you to a polling group, it will appear here.
-
-
- )}
-
- {invitations.length > 0 && (
-
-
Pending Invitations
- {invitations.map((invitation) => (
-
-
-
- {invitation.credentialSetName}
-
-
- {invitation.organizationName}
-
+ <>
+
+
+
+ {hasNoContent && !canManageCredentialSets && (
+
+ You're not a member of any polling groups yet. When someone invites you, it will
+ appear here.
-
handleAcceptInvitation(invitation.token)}
- disabled={acceptInvitation.isPending}
- >
- {acceptInvitation.isPending ? (
-
- ) : (
- 'Accept'
- )}
-
-
- ))}
-
- )}
-
- {activeMemberships.length > 0 && (
-
-
My Memberships
- {activeMemberships.map((membership) => (
-
-
-
- {membership.credentialSetName}
-
-
- {membership.organizationName}
-
+ )}
+
+ {invitations.length > 0 && (
+
+
+ Pending Invitations
+
+ {invitations.map((invitation) => (
+
+
+
+
+
+
+
+ {invitation.credentialSetName}
+
+
+ {invitation.organizationName}
+
+
+
+
handleAcceptInvitation(invitation.token)}
+ disabled={acceptInvitation.isPending}
+ >
+ {acceptInvitation.isPending ? (
+
+ ) : (
+ 'Accept'
+ )}
+
+
+ ))}
-
- handleLeave(membership.credentialSetId, membership.credentialSetName)
- }
- disabled={leaveCredentialSet.isPending}
- className='text-[var(--text-secondary)] hover:text-[var(--text-error)]'
- >
- {leaveCredentialSet.isPending ? (
-
- ) : (
-
- )}
-
-
- ))}
-
- )}
+ )}
- {canManageCredentialSets && (
-
-
-
Manage
-
setShowCreateModal(true)}>
-
- Create
-
-
+ {activeMemberships.length > 0 && (
+
+
+ My Memberships
+
+ {activeMemberships.map((membership) => (
+
+
+
+
+
+
+
+ {membership.credentialSetName}
+
+
+ {membership.organizationName}
+
+
+
+
+ handleLeave(membership.credentialSetId, membership.credentialSetName)
+ }
+ disabled={leaveCredentialSet.isPending}
+ >
+ Leave
+
+
+ ))}
+
+ )}
- {ownedSetsLoading ? (
- <>
-
-
- >
- ) : ownedSets.length === 0 ? (
-
-
- No polling groups created yet
-
-
- ) : (
- ownedSets.map((set) => (
-
setViewingSet(set)}
- >
-
-
- {set.name}
-
-
- {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
- {set.providerId && <> · {getProviderDisplayName(set.providerId)}>}
-
+ {canManageCredentialSets && (
+
+
+
Manage
+
setShowCreateModal(true)}>
+
+ Create
+
-
{
- e.stopPropagation()
- setSelectedSetId(set.id)
- setShowInviteModal(true)
- }}
- >
- Add Members
-
+ {ownedSetsLoading ? (
+ <>
+ {[1, 2].map((i) => (
+
+ ))}
+ >
+ ) : ownedSets.length === 0 ? (
+
+ No polling groups created yet
+
+ ) : (
+ ownedSets.map((set) => (
+
setViewingSet(set)}
+ >
+
+
+ {getProviderIcon(set.providerId)}
+
+
+ {set.name}
+
+ {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+
+
+
+
{
+ e.stopPropagation()
+ setSelectedSetId(set.id)
+ setShowInviteModal(true)
+ }}
+ >
+ Add Members
+
+
+ ))
+ )}
- ))
- )}
+ )}
+
- )}
+
-
+
Create Polling Group
-
+
Name
Email Provider
-
+
setNewSetProvider('google-email')}
- className='flex-1'
+ className={cn(
+ 'rounded-r-none px-[8px] py-[4px] text-[12px]',
+ newSetProvider === 'google-email' &&
+ 'bg-[var(--border-1)] hover:bg-[var(--border-1)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
+ )}
>
-
Gmail
setNewSetProvider('outlook')}
- className='flex-1'
+ className={cn(
+ 'rounded-l-none px-[8px] py-[4px] text-[12px]',
+ newSetProvider === 'outlook' &&
+ 'bg-[var(--border-1)] hover:bg-[var(--border-1)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
+ )}
>
-
Outlook
- Members will connect their {getProviderDisplayName(newSetProvider)} account for
- email polling
+ Members will connect their {getProviderDisplayName(newSetProvider)} account
{createError &&
{createError}
}
@@ -643,7 +698,7 @@ export function CredentialSets() {
disabled={!newSetName.trim() || createCredentialSet.isPending}
>
{createCredentialSet.isPending ? (
-
+
) : (
'Create'
)}
@@ -653,34 +708,32 @@ export function CredentialSets() {
-
+
Add Members
-
-
-
Email Addresses
-
+
+
Email Addresses
+
-
+
Invitees will receive an email with a link to connect their account.
@@ -695,7 +748,7 @@ export function CredentialSets() {
disabled={createInvitation.isPending || !inviteEmails.trim()}
>
{createInvitation.isPending ? (
-
+
) : (
'Send Invites'
)}
@@ -717,7 +770,7 @@ export function CredentialSets() {
- setLeavingMembership(null)}>
+ setLeavingMembership(null)}>
Cancel
-
+ >
)
}
From 71693c365994aad4487d550bb952841d669c0d79 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 14:20:28 -0800
Subject: [PATCH 08/21] fix tests
---
.../api/auth/oauth/disconnect/route.test.ts | 22 +
.../app/api/auth/oauth/token/route.test.ts | 5 +-
.../api/webhooks/trigger/[path]/route.test.ts | 104 +
apps/sim/triggers/constants.ts | 1 -
.../db/migrations/0135_stormy_puff_adder.sql | 61 +
.../db/migrations/meta/0135_snapshot.json | 9333 +++++++++++++++++
packages/db/migrations/meta/_journal.json | 7 +
7 files changed, 9531 insertions(+), 2 deletions(-)
create mode 100644 packages/db/migrations/0135_stormy_puff_adder.sql
create mode 100644 packages/db/migrations/meta/0135_snapshot.json
diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts
index 9cd956fee6..7f625d2539 100644
--- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts
+++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts
@@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut
describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()
+ const mockSelectChain = {
+ from: vi.fn().mockReturnThis(),
+ innerJoin: vi.fn().mockReturnThis(),
+ where: vi.fn().mockResolvedValue([]),
+ }
const mockDb = {
delete: vi.fn().mockReturnThis(),
where: vi.fn(),
+ select: vi.fn().mockReturnValue(mockSelectChain),
}
const mockLogger = createMockLogger()
+ const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({})
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
@@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
+ credentialSetMember: {
+ id: 'id',
+ credentialSetId: 'credentialSetId',
+ userId: 'userId',
+ status: 'status',
+ },
+ credentialSet: { id: 'id', providerId: 'providerId' },
}))
vi.doMock('drizzle-orm', () => ({
@@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
+
+ vi.doMock('@/lib/core/utils/request', () => ({
+ generateRequestId: vi.fn().mockReturnValue('test-request-id'),
+ }))
+
+ vi.doMock('@/lib/webhooks/utils.server', () => ({
+ syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
+ }))
})
afterEach(() => {
diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts
index 4d22039777..7359361a40 100644
--- a/apps/sim/app/api/auth/oauth/token/route.test.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.test.ts
@@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(400)
- expect(data).toHaveProperty('error', 'Credential ID is required')
+ expect(data).toHaveProperty(
+ 'error',
+ 'Either credentialId or (credentialAccountUserId + providerId) is required'
+ )
expect(mockLogger.warn).toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
index e736db3987..d5a0555c14 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
@@ -172,6 +172,106 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
}))
+vi.mock('@/lib/webhooks/processor', () => ({
+ findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => {
+ // Filter webhooks by path from globalMockData
+ const matchingWebhooks = globalMockData.webhooks.filter(
+ (wh) => wh.path === options.path && wh.isActive
+ )
+
+ if (matchingWebhooks.length === 0) {
+ return []
+ }
+
+ // Return array of {webhook, workflow} objects
+ return matchingWebhooks.map((wh) => {
+ const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || {
+ id: wh.workflowId || 'test-workflow-id',
+ userId: 'test-user-id',
+ workspaceId: 'test-workspace-id',
+ }
+ return {
+ webhook: wh,
+ workflow: matchingWorkflow,
+ }
+ })
+ }),
+ parseWebhookBody: vi.fn().mockImplementation(async (request: any) => {
+ try {
+ const cloned = request.clone()
+ const rawBody = await cloned.text()
+ const body = rawBody ? JSON.parse(rawBody) : {}
+ return { body, rawBody }
+ } catch {
+ return { body: {}, rawBody: '' }
+ }
+ }),
+ handleProviderChallenges: vi.fn().mockResolvedValue(null),
+ handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
+ verifyProviderAuth: vi
+ .fn()
+ .mockImplementation(
+ async (
+ foundWebhook: any,
+ _foundWorkflow: any,
+ request: any,
+ _rawBody: string,
+ _requestId: string
+ ) => {
+ // Implement generic webhook auth verification for tests
+ if (foundWebhook.provider === 'generic') {
+ const providerConfig = foundWebhook.providerConfig || {}
+ if (providerConfig.requireAuth) {
+ const configToken = providerConfig.token
+ const secretHeaderName = providerConfig.secretHeaderName
+
+ if (configToken) {
+ let isTokenValid = false
+
+ if (secretHeaderName) {
+ // Custom header auth
+ const headerValue = request.headers.get(secretHeaderName.toLowerCase())
+ if (headerValue === configToken) {
+ isTokenValid = true
+ }
+ } else {
+ // Bearer token auth
+ const authHeader = request.headers.get('authorization')
+ if (authHeader?.toLowerCase().startsWith('bearer ')) {
+ const token = authHeader.substring(7)
+ if (token === configToken) {
+ isTokenValid = true
+ }
+ }
+ }
+
+ if (!isTokenValid) {
+ const { NextResponse } = await import('next/server')
+ return new NextResponse('Unauthorized - Invalid authentication token', {
+ status: 401,
+ })
+ }
+ } else {
+ // Auth required but no token configured
+ const { NextResponse } = await import('next/server')
+ return new NextResponse('Unauthorized - Authentication required but not configured', {
+ status: 401,
+ })
+ }
+ }
+ }
+ return null
+ }
+ ),
+ checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
+ queueWebhookExecution: vi.fn().mockImplementation(async () => {
+ // Call processWebhookMock so tests can verify it was called
+ processWebhookMock()
+ const { NextResponse } = await import('next/server')
+ return NextResponse.json({ message: 'Webhook processed' })
+ }),
+}))
+
vi.mock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn().mockReturnValue({}),
}))
@@ -180,6 +280,10 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({}))
vi.mock('@sim/logger', () => loggerMock)
+vi.mock('@/lib/core/utils/request', () => ({
+ generateRequestId: vi.fn().mockReturnValue('test-request-id'),
+}))
+
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts
index 261634f0cf..d731246248 100644
--- a/apps/sim/triggers/constants.ts
+++ b/apps/sim/triggers/constants.ts
@@ -13,7 +13,6 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [
'setupScript', // Setup script code (e.g., Apps Script)
'triggerId', // Stored trigger ID
'selectedTriggerId', // Selected trigger from dropdown (multi-trigger blocks)
- 'triggerConfig', // Aggregated trigger config (derived from individual fields)
]
/**
diff --git a/packages/db/migrations/0135_stormy_puff_adder.sql b/packages/db/migrations/0135_stormy_puff_adder.sql
new file mode 100644
index 0000000000..1e4e248529
--- /dev/null
+++ b/packages/db/migrations/0135_stormy_puff_adder.sql
@@ -0,0 +1,61 @@
+CREATE TYPE "public"."credential_set_invitation_status" AS ENUM('pending', 'accepted', 'expired', 'cancelled');--> statement-breakpoint
+CREATE TYPE "public"."credential_set_member_status" AS ENUM('active', 'pending', 'revoked');--> statement-breakpoint
+CREATE TABLE "credential_set" (
+ "id" text PRIMARY KEY NOT NULL,
+ "organization_id" text NOT NULL,
+ "name" text NOT NULL,
+ "description" text,
+ "provider_id" text NOT NULL,
+ "created_by" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "credential_set_invitation" (
+ "id" text PRIMARY KEY NOT NULL,
+ "credential_set_id" text NOT NULL,
+ "email" text,
+ "token" text NOT NULL,
+ "invited_by" text NOT NULL,
+ "status" "credential_set_invitation_status" DEFAULT 'pending' NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "accepted_at" timestamp,
+ "accepted_by_user_id" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "credential_set_invitation_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+CREATE TABLE "credential_set_member" (
+ "id" text PRIMARY KEY NOT NULL,
+ "credential_set_id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "status" "credential_set_member_status" DEFAULT 'pending' NOT NULL,
+ "joined_at" timestamp,
+ "invited_by" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "webhook" ADD COLUMN "credential_set_id" text;--> statement-breakpoint
+ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_accepted_by_user_id_user_id_fk" FOREIGN KEY ("accepted_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "credential_set_organization_id_idx" ON "credential_set" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "credential_set_created_by_idx" ON "credential_set" USING btree ("created_by");--> statement-breakpoint
+CREATE UNIQUE INDEX "credential_set_org_name_unique" ON "credential_set" USING btree ("organization_id","name");--> statement-breakpoint
+CREATE INDEX "credential_set_provider_id_idx" ON "credential_set" USING btree ("provider_id");--> statement-breakpoint
+CREATE INDEX "credential_set_invitation_set_id_idx" ON "credential_set_invitation" USING btree ("credential_set_id");--> statement-breakpoint
+CREATE INDEX "credential_set_invitation_token_idx" ON "credential_set_invitation" USING btree ("token");--> statement-breakpoint
+CREATE INDEX "credential_set_invitation_status_idx" ON "credential_set_invitation" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "credential_set_invitation_expires_at_idx" ON "credential_set_invitation" USING btree ("expires_at");--> statement-breakpoint
+CREATE INDEX "credential_set_member_set_id_idx" ON "credential_set_member" USING btree ("credential_set_id");--> statement-breakpoint
+CREATE INDEX "credential_set_member_user_id_idx" ON "credential_set_member" USING btree ("user_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "credential_set_member_unique" ON "credential_set_member" USING btree ("credential_set_id","user_id");--> statement-breakpoint
+CREATE INDEX "credential_set_member_status_idx" ON "credential_set_member" USING btree ("status");--> statement-breakpoint
+ALTER TABLE "webhook" ADD CONSTRAINT "webhook_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "webhook_credential_set_id_idx" ON "webhook" USING btree ("credential_set_id");
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0135_snapshot.json b/packages/db/migrations/meta/0135_snapshot.json
new file mode 100644
index 0000000000..26f9899a23
--- /dev/null
+++ b/packages/db/migrations/meta/0135_snapshot.json
@@ -0,0 +1,9333 @@
+{
+ "id": "27c797c9-6ab1-4fe8-96ae-9242c4d427f0",
+ "prevId": "30899c79-8b72-46e7-a7b0-d9fe760c59ea",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_account_on_account_id_provider_id": {
+ "name": "idx_account_on_account_id_provider_id",
+ "columns": [
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_user_provider_account_unique": {
+ "name": "account_user_provider_account_unique",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_key": {
+ "name": "api_key",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'personal'"
+ },
+ "last_used": {
+ "name": "last_used",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "api_key_workspace_type_idx": {
+ "name": "api_key_workspace_type_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "api_key_user_type_idx": {
+ "name": "api_key_user_type_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "api_key_user_id_user_id_fk": {
+ "name": "api_key_user_id_user_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "api_key_workspace_id_workspace_id_fk": {
+ "name": "api_key_workspace_id_workspace_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "api_key_created_by_user_id_fk": {
+ "name": "api_key_created_by_user_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_key_key_unique": {
+ "name": "api_key_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "workspace_type_check": {
+ "name": "workspace_type_check",
+ "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.chat": {
+ "name": "chat",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "customizations": {
+ "name": "customizations",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'public'"
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowed_emails": {
+ "name": "allowed_emails",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'"
+ },
+ "output_configs": {
+ "name": "output_configs",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "identifier_idx": {
+ "name": "identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_workflow_id_workflow_id_fk": {
+ "name": "chat_workflow_id_workflow_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_user_id_user_id_fk": {
+ "name": "chat_user_id_user_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.copilot_chats": {
+ "name": "copilot_chats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "messages": {
+ "name": "messages",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'claude-3-7-sonnet-latest'"
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preview_yaml": {
+ "name": "preview_yaml",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan_artifact": {
+ "name": "plan_artifact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "copilot_chats_user_id_idx": {
+ "name": "copilot_chats_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_chats_workflow_id_idx": {
+ "name": "copilot_chats_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_chats_user_workflow_idx": {
+ "name": "copilot_chats_user_workflow_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_chats_created_at_idx": {
+ "name": "copilot_chats_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_chats_updated_at_idx": {
+ "name": "copilot_chats_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "copilot_chats_user_id_user_id_fk": {
+ "name": "copilot_chats_user_id_user_id_fk",
+ "tableFrom": "copilot_chats",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "copilot_chats_workflow_id_workflow_id_fk": {
+ "name": "copilot_chats_workflow_id_workflow_id_fk",
+ "tableFrom": "copilot_chats",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.copilot_feedback": {
+ "name": "copilot_feedback",
+ "schema": "",
+ "columns": {
+ "feedback_id": {
+ "name": "feedback_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_query": {
+ "name": "user_query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_response": {
+ "name": "agent_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_positive": {
+ "name": "is_positive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feedback": {
+ "name": "feedback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow_yaml": {
+ "name": "workflow_yaml",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "copilot_feedback_user_id_idx": {
+ "name": "copilot_feedback_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_feedback_chat_id_idx": {
+ "name": "copilot_feedback_chat_id_idx",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_feedback_user_chat_idx": {
+ "name": "copilot_feedback_user_chat_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_feedback_is_positive_idx": {
+ "name": "copilot_feedback_is_positive_idx",
+ "columns": [
+ {
+ "expression": "is_positive",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "copilot_feedback_created_at_idx": {
+ "name": "copilot_feedback_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "copilot_feedback_user_id_user_id_fk": {
+ "name": "copilot_feedback_user_id_user_id_fk",
+ "tableFrom": "copilot_feedback",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "copilot_feedback_chat_id_copilot_chats_id_fk": {
+ "name": "copilot_feedback_chat_id_copilot_chats_id_fk",
+ "tableFrom": "copilot_feedback",
+ "tableTo": "copilot_chats",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credential_set": {
+ "name": "credential_set",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credential_set_organization_id_idx": {
+ "name": "credential_set_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_created_by_idx": {
+ "name": "credential_set_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_org_name_unique": {
+ "name": "credential_set_org_name_unique",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_provider_id_idx": {
+ "name": "credential_set_provider_id_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "credential_set_organization_id_organization_id_fk": {
+ "name": "credential_set_organization_id_organization_id_fk",
+ "tableFrom": "credential_set",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "credential_set_created_by_user_id_fk": {
+ "name": "credential_set_created_by_user_id_fk",
+ "tableFrom": "credential_set",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credential_set_invitation": {
+ "name": "credential_set_invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "credential_set_id": {
+ "name": "credential_set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "credential_set_invitation_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accepted_at": {
+ "name": "accepted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accepted_by_user_id": {
+ "name": "accepted_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credential_set_invitation_set_id_idx": {
+ "name": "credential_set_invitation_set_id_idx",
+ "columns": [
+ {
+ "expression": "credential_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_invitation_token_idx": {
+ "name": "credential_set_invitation_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_invitation_status_idx": {
+ "name": "credential_set_invitation_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_invitation_expires_at_idx": {
+ "name": "credential_set_invitation_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "credential_set_invitation_credential_set_id_credential_set_id_fk": {
+ "name": "credential_set_invitation_credential_set_id_credential_set_id_fk",
+ "tableFrom": "credential_set_invitation",
+ "tableTo": "credential_set",
+ "columnsFrom": ["credential_set_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "credential_set_invitation_invited_by_user_id_fk": {
+ "name": "credential_set_invitation_invited_by_user_id_fk",
+ "tableFrom": "credential_set_invitation",
+ "tableTo": "user",
+ "columnsFrom": ["invited_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "credential_set_invitation_accepted_by_user_id_user_id_fk": {
+ "name": "credential_set_invitation_accepted_by_user_id_user_id_fk",
+ "tableFrom": "credential_set_invitation",
+ "tableTo": "user",
+ "columnsFrom": ["accepted_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "credential_set_invitation_token_unique": {
+ "name": "credential_set_invitation_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credential_set_member": {
+ "name": "credential_set_member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "credential_set_id": {
+ "name": "credential_set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "credential_set_member_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credential_set_member_set_id_idx": {
+ "name": "credential_set_member_set_id_idx",
+ "columns": [
+ {
+ "expression": "credential_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_member_user_id_idx": {
+ "name": "credential_set_member_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_member_unique": {
+ "name": "credential_set_member_unique",
+ "columns": [
+ {
+ "expression": "credential_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "credential_set_member_status_idx": {
+ "name": "credential_set_member_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "credential_set_member_credential_set_id_credential_set_id_fk": {
+ "name": "credential_set_member_credential_set_id_credential_set_id_fk",
+ "tableFrom": "credential_set_member",
+ "tableTo": "credential_set",
+ "columnsFrom": ["credential_set_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "credential_set_member_user_id_user_id_fk": {
+ "name": "credential_set_member_user_id_user_id_fk",
+ "tableFrom": "credential_set_member",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "credential_set_member_invited_by_user_id_fk": {
+ "name": "credential_set_member_invited_by_user_id_fk",
+ "tableFrom": "credential_set_member",
+ "tableTo": "user",
+ "columnsFrom": ["invited_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_tools": {
+ "name": "custom_tools",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema": {
+ "name": "schema",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "custom_tools_workspace_id_idx": {
+ "name": "custom_tools_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "custom_tools_workspace_title_unique": {
+ "name": "custom_tools_workspace_title_unique",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "custom_tools_workspace_id_workspace_id_fk": {
+ "name": "custom_tools_workspace_id_workspace_id_fk",
+ "tableFrom": "custom_tools",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "custom_tools_user_id_user_id_fk": {
+ "name": "custom_tools_user_id_user_id_fk",
+ "tableFrom": "custom_tools",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.docs_embeddings": {
+ "name": "docs_embeddings",
+ "schema": "",
+ "columns": {
+ "chunk_id": {
+ "name": "chunk_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "chunk_text": {
+ "name": "chunk_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_document": {
+ "name": "source_document",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_link": {
+ "name": "source_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "header_text": {
+ "name": "header_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "header_level": {
+ "name": "header_level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_count": {
+ "name": "token_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding_model": {
+ "name": "embedding_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'text-embedding-3-small'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "chunk_text_tsv": {
+ "name": "chunk_text_tsv",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": false,
+ "generated": {
+ "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")",
+ "type": "stored"
+ }
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "docs_emb_source_document_idx": {
+ "name": "docs_emb_source_document_idx",
+ "columns": [
+ {
+ "expression": "source_document",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "docs_emb_header_level_idx": {
+ "name": "docs_emb_header_level_idx",
+ "columns": [
+ {
+ "expression": "header_level",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "docs_emb_source_header_idx": {
+ "name": "docs_emb_source_header_idx",
+ "columns": [
+ {
+ "expression": "source_document",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "header_level",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "docs_emb_model_idx": {
+ "name": "docs_emb_model_idx",
+ "columns": [
+ {
+ "expression": "embedding_model",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "docs_emb_created_at_idx": {
+ "name": "docs_emb_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "docs_embedding_vector_hnsw_idx": {
+ "name": "docs_embedding_vector_hnsw_idx",
+ "columns": [
+ {
+ "expression": "embedding",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "vector_cosine_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "hnsw",
+ "with": {
+ "m": 16,
+ "ef_construction": 64
+ }
+ },
+ "docs_emb_metadata_gin_idx": {
+ "name": "docs_emb_metadata_gin_idx",
+ "columns": [
+ {
+ "expression": "metadata",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "docs_emb_chunk_text_fts_idx": {
+ "name": "docs_emb_chunk_text_fts_idx",
+ "columns": [
+ {
+ "expression": "chunk_text_tsv",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "docs_embedding_not_null_check": {
+ "name": "docs_embedding_not_null_check",
+ "value": "\"embedding\" IS NOT NULL"
+ },
+ "docs_header_level_check": {
+ "name": "docs_header_level_check",
+ "value": "\"header_level\" >= 1 AND \"header_level\" <= 6"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.document": {
+ "name": "document",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "knowledge_base_id": {
+ "name": "knowledge_base_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_url": {
+ "name": "file_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chunk_count": {
+ "name": "chunk_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "token_count": {
+ "name": "token_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "character_count": {
+ "name": "character_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "processing_status": {
+ "name": "processing_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "processing_started_at": {
+ "name": "processing_started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processing_completed_at": {
+ "name": "processing_completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processing_error": {
+ "name": "processing_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag1": {
+ "name": "tag1",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag2": {
+ "name": "tag2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag3": {
+ "name": "tag3",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag4": {
+ "name": "tag4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag5": {
+ "name": "tag5",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag6": {
+ "name": "tag6",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag7": {
+ "name": "tag7",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number1": {
+ "name": "number1",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number2": {
+ "name": "number2",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number3": {
+ "name": "number3",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number4": {
+ "name": "number4",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number5": {
+ "name": "number5",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date1": {
+ "name": "date1",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date2": {
+ "name": "date2",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean1": {
+ "name": "boolean1",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean2": {
+ "name": "boolean2",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean3": {
+ "name": "boolean3",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "doc_kb_id_idx": {
+ "name": "doc_kb_id_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_filename_idx": {
+ "name": "doc_filename_idx",
+ "columns": [
+ {
+ "expression": "filename",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_processing_status_idx": {
+ "name": "doc_processing_status_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "processing_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag1_idx": {
+ "name": "doc_tag1_idx",
+ "columns": [
+ {
+ "expression": "tag1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag2_idx": {
+ "name": "doc_tag2_idx",
+ "columns": [
+ {
+ "expression": "tag2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag3_idx": {
+ "name": "doc_tag3_idx",
+ "columns": [
+ {
+ "expression": "tag3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag4_idx": {
+ "name": "doc_tag4_idx",
+ "columns": [
+ {
+ "expression": "tag4",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag5_idx": {
+ "name": "doc_tag5_idx",
+ "columns": [
+ {
+ "expression": "tag5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag6_idx": {
+ "name": "doc_tag6_idx",
+ "columns": [
+ {
+ "expression": "tag6",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_tag7_idx": {
+ "name": "doc_tag7_idx",
+ "columns": [
+ {
+ "expression": "tag7",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_number1_idx": {
+ "name": "doc_number1_idx",
+ "columns": [
+ {
+ "expression": "number1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_number2_idx": {
+ "name": "doc_number2_idx",
+ "columns": [
+ {
+ "expression": "number2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_number3_idx": {
+ "name": "doc_number3_idx",
+ "columns": [
+ {
+ "expression": "number3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_number4_idx": {
+ "name": "doc_number4_idx",
+ "columns": [
+ {
+ "expression": "number4",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_number5_idx": {
+ "name": "doc_number5_idx",
+ "columns": [
+ {
+ "expression": "number5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_date1_idx": {
+ "name": "doc_date1_idx",
+ "columns": [
+ {
+ "expression": "date1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_date2_idx": {
+ "name": "doc_date2_idx",
+ "columns": [
+ {
+ "expression": "date2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_boolean1_idx": {
+ "name": "doc_boolean1_idx",
+ "columns": [
+ {
+ "expression": "boolean1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_boolean2_idx": {
+ "name": "doc_boolean2_idx",
+ "columns": [
+ {
+ "expression": "boolean2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "doc_boolean3_idx": {
+ "name": "doc_boolean3_idx",
+ "columns": [
+ {
+ "expression": "boolean3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_knowledge_base_id_knowledge_base_id_fk": {
+ "name": "document_knowledge_base_id_knowledge_base_id_fk",
+ "tableFrom": "document",
+ "tableTo": "knowledge_base",
+ "columnsFrom": ["knowledge_base_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embedding": {
+ "name": "embedding",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "knowledge_base_id": {
+ "name": "knowledge_base_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chunk_index": {
+ "name": "chunk_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chunk_hash": {
+ "name": "chunk_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_length": {
+ "name": "content_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_count": {
+ "name": "token_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding_model": {
+ "name": "embedding_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'text-embedding-3-small'"
+ },
+ "start_offset": {
+ "name": "start_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_offset": {
+ "name": "end_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag1": {
+ "name": "tag1",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag2": {
+ "name": "tag2",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag3": {
+ "name": "tag3",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag4": {
+ "name": "tag4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag5": {
+ "name": "tag5",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag6": {
+ "name": "tag6",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tag7": {
+ "name": "tag7",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number1": {
+ "name": "number1",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number2": {
+ "name": "number2",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number3": {
+ "name": "number3",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number4": {
+ "name": "number4",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number5": {
+ "name": "number5",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date1": {
+ "name": "date1",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date2": {
+ "name": "date2",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean1": {
+ "name": "boolean1",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean2": {
+ "name": "boolean2",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "boolean3": {
+ "name": "boolean3",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "content_tsv": {
+ "name": "content_tsv",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": false,
+ "generated": {
+ "as": "to_tsvector('english', \"embedding\".\"content\")",
+ "type": "stored"
+ }
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "emb_kb_id_idx": {
+ "name": "emb_kb_id_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_doc_id_idx": {
+ "name": "emb_doc_id_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_doc_chunk_idx": {
+ "name": "emb_doc_chunk_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chunk_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_kb_model_idx": {
+ "name": "emb_kb_model_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "embedding_model",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_kb_enabled_idx": {
+ "name": "emb_kb_enabled_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_doc_enabled_idx": {
+ "name": "emb_doc_enabled_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "embedding_vector_hnsw_idx": {
+ "name": "embedding_vector_hnsw_idx",
+ "columns": [
+ {
+ "expression": "embedding",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "vector_cosine_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "hnsw",
+ "with": {
+ "m": 16,
+ "ef_construction": 64
+ }
+ },
+ "emb_tag1_idx": {
+ "name": "emb_tag1_idx",
+ "columns": [
+ {
+ "expression": "tag1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag2_idx": {
+ "name": "emb_tag2_idx",
+ "columns": [
+ {
+ "expression": "tag2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag3_idx": {
+ "name": "emb_tag3_idx",
+ "columns": [
+ {
+ "expression": "tag3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag4_idx": {
+ "name": "emb_tag4_idx",
+ "columns": [
+ {
+ "expression": "tag4",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag5_idx": {
+ "name": "emb_tag5_idx",
+ "columns": [
+ {
+ "expression": "tag5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag6_idx": {
+ "name": "emb_tag6_idx",
+ "columns": [
+ {
+ "expression": "tag6",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_tag7_idx": {
+ "name": "emb_tag7_idx",
+ "columns": [
+ {
+ "expression": "tag7",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_number1_idx": {
+ "name": "emb_number1_idx",
+ "columns": [
+ {
+ "expression": "number1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_number2_idx": {
+ "name": "emb_number2_idx",
+ "columns": [
+ {
+ "expression": "number2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_number3_idx": {
+ "name": "emb_number3_idx",
+ "columns": [
+ {
+ "expression": "number3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_number4_idx": {
+ "name": "emb_number4_idx",
+ "columns": [
+ {
+ "expression": "number4",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_number5_idx": {
+ "name": "emb_number5_idx",
+ "columns": [
+ {
+ "expression": "number5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_date1_idx": {
+ "name": "emb_date1_idx",
+ "columns": [
+ {
+ "expression": "date1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_date2_idx": {
+ "name": "emb_date2_idx",
+ "columns": [
+ {
+ "expression": "date2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_boolean1_idx": {
+ "name": "emb_boolean1_idx",
+ "columns": [
+ {
+ "expression": "boolean1",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_boolean2_idx": {
+ "name": "emb_boolean2_idx",
+ "columns": [
+ {
+ "expression": "boolean2",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_boolean3_idx": {
+ "name": "emb_boolean3_idx",
+ "columns": [
+ {
+ "expression": "boolean3",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "emb_content_fts_idx": {
+ "name": "emb_content_fts_idx",
+ "columns": [
+ {
+ "expression": "content_tsv",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "embedding_knowledge_base_id_knowledge_base_id_fk": {
+ "name": "embedding_knowledge_base_id_knowledge_base_id_fk",
+ "tableFrom": "embedding",
+ "tableTo": "knowledge_base",
+ "columnsFrom": ["knowledge_base_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "embedding_document_id_document_id_fk": {
+ "name": "embedding_document_id_document_id_fk",
+ "tableFrom": "embedding",
+ "tableTo": "document",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "embedding_not_null_check": {
+ "name": "embedding_not_null_check",
+ "value": "\"embedding\" IS NOT NULL"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.environment": {
+ "name": "environment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variables": {
+ "name": "variables",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "environment_user_id_user_id_fk": {
+ "name": "environment_user_id_user_id_fk",
+ "tableFrom": "environment",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "environment_user_id_unique": {
+ "name": "environment_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.idempotency_key": {
+ "name": "idempotency_key",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "result": {
+ "name": "result",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idempotency_key_namespace_unique": {
+ "name": "idempotency_key_namespace_unique",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "namespace",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idempotency_key_created_at_idx": {
+ "name": "idempotency_key_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idempotency_key_namespace_idx": {
+ "name": "idempotency_key_namespace_idx",
+ "columns": [
+ {
+ "expression": "namespace",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_organization_id_idx": {
+ "name": "invitation_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": ["inviter_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knowledge_base": {
+ "name": "knowledge_base",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_count": {
+ "name": "token_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "embedding_model": {
+ "name": "embedding_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'text-embedding-3-small'"
+ },
+ "embedding_dimension": {
+ "name": "embedding_dimension",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1536
+ },
+ "chunking_config": {
+ "name": "chunking_config",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kb_user_id_idx": {
+ "name": "kb_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kb_workspace_id_idx": {
+ "name": "kb_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kb_user_workspace_idx": {
+ "name": "kb_user_workspace_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kb_deleted_at_idx": {
+ "name": "kb_deleted_at_idx",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "knowledge_base_user_id_user_id_fk": {
+ "name": "knowledge_base_user_id_user_id_fk",
+ "tableFrom": "knowledge_base",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "knowledge_base_workspace_id_workspace_id_fk": {
+ "name": "knowledge_base_workspace_id_workspace_id_fk",
+ "tableFrom": "knowledge_base",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.knowledge_base_tag_definitions": {
+ "name": "knowledge_base_tag_definitions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "knowledge_base_id": {
+ "name": "knowledge_base_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_slot": {
+ "name": "tag_slot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_type": {
+ "name": "field_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'text'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kb_tag_definitions_kb_slot_idx": {
+ "name": "kb_tag_definitions_kb_slot_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tag_slot",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kb_tag_definitions_kb_display_name_idx": {
+ "name": "kb_tag_definitions_kb_display_name_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kb_tag_definitions_kb_id_idx": {
+ "name": "kb_tag_definitions_kb_id_idx",
+ "columns": [
+ {
+ "expression": "knowledge_base_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": {
+ "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk",
+ "tableFrom": "knowledge_base_tag_definitions",
+ "tableTo": "knowledge_base",
+ "columnsFrom": ["knowledge_base_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transport": {
+ "name": "transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headers": {
+ "name": "headers",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "timeout": {
+ "name": "timeout",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 30000
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 3
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_connected": {
+ "name": "last_connected",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_status": {
+ "name": "connection_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'disconnected'"
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_config": {
+ "name": "status_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "tool_count": {
+ "name": "tool_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "last_tools_refresh": {
+ "name": "last_tools_refresh",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_requests": {
+ "name": "total_requests",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "last_used": {
+ "name": "last_used",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_servers_workspace_enabled_idx": {
+ "name": "mcp_servers_workspace_enabled_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_servers_workspace_deleted_idx": {
+ "name": "mcp_servers_workspace_deleted_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_servers_workspace_id_workspace_id_fk": {
+ "name": "mcp_servers_workspace_id_workspace_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_servers_created_by_user_id_fk": {
+ "name": "mcp_servers_created_by_user_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "member_user_id_idx": {
+ "name": "member_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_organization_id_idx": {
+ "name": "member_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.memory": {
+ "name": "memory",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "memory_key_idx": {
+ "name": "memory_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "memory_workspace_idx": {
+ "name": "memory_workspace_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "memory_workspace_key_idx": {
+ "name": "memory_workspace_key_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "memory_workspace_id_workspace_id_fk": {
+ "name": "memory_workspace_id_workspace_id_fk",
+ "tableFrom": "memory",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "org_usage_limit": {
+ "name": "org_usage_limit",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "storage_used_bytes": {
+ "name": "storage_used_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "departed_member_usage": {
+ "name": "departed_member_usage",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "credit_balance": {
+ "name": "credit_balance",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paused_executions": {
+ "name": "paused_executions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_id": {
+ "name": "execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_snapshot": {
+ "name": "execution_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pause_points": {
+ "name": "pause_points",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_pause_count": {
+ "name": "total_pause_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resumed_count": {
+ "name": "resumed_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paused'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "paused_executions_workflow_id_idx": {
+ "name": "paused_executions_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paused_executions_status_idx": {
+ "name": "paused_executions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paused_executions_execution_id_unique": {
+ "name": "paused_executions_execution_id_unique",
+ "columns": [
+ {
+ "expression": "execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paused_executions_workflow_id_workflow_id_fk": {
+ "name": "paused_executions_workflow_id_workflow_id_fk",
+ "tableFrom": "paused_executions",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permissions": {
+ "name": "permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_type": {
+ "name": "permission_type",
+ "type": "permission_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "permissions_user_id_idx": {
+ "name": "permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "permissions_entity_idx": {
+ "name": "permissions_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "permissions_user_entity_type_idx": {
+ "name": "permissions_user_entity_type_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "permissions_user_entity_permission_idx": {
+ "name": "permissions_user_entity_permission_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "permission_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "permissions_user_entity_idx": {
+ "name": "permissions_user_entity_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "permissions_unique_constraint": {
+ "name": "permissions_unique_constraint",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "permissions_user_id_user_id_fk": {
+ "name": "permissions_user_id_user_id_fk",
+ "tableFrom": "permissions",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_limit_bucket": {
+ "name": "rate_limit_bucket",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tokens": {
+ "name": "tokens",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_refill_at": {
+ "name": "last_refill_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.resume_queue": {
+ "name": "resume_queue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "paused_execution_id": {
+ "name": "paused_execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_execution_id": {
+ "name": "parent_execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "new_execution_id": {
+ "name": "new_execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "context_id": {
+ "name": "context_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resume_input": {
+ "name": "resume_input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "queued_at": {
+ "name": "queued_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "resume_queue_parent_status_idx": {
+ "name": "resume_queue_parent_status_idx",
+ "columns": [
+ {
+ "expression": "parent_execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "queued_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "resume_queue_new_execution_idx": {
+ "name": "resume_queue_new_execution_idx",
+ "columns": [
+ {
+ "expression": "new_execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "resume_queue_paused_execution_id_paused_executions_id_fk": {
+ "name": "resume_queue_paused_execution_id_paused_executions_id_fk",
+ "tableFrom": "resume_queue",
+ "tableTo": "paused_executions",
+ "columnsFrom": ["paused_execution_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "session_token_idx": {
+ "name": "session_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "session_active_organization_id_organization_id_fk": {
+ "name": "session_active_organization_id_organization_id_fk",
+ "tableFrom": "session",
+ "tableTo": "organization",
+ "columnsFrom": ["active_organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "theme": {
+ "name": "theme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "auto_connect": {
+ "name": "auto_connect",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "telemetry_enabled": {
+ "name": "telemetry_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "email_preferences": {
+ "name": "email_preferences",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "billing_usage_notifications_enabled": {
+ "name": "billing_usage_notifications_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "show_training_controls": {
+ "name": "show_training_controls",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "super_user_mode_enabled": {
+ "name": "super_user_mode_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "error_notifications_enabled": {
+ "name": "error_notifications_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "snap_to_grid_size": {
+ "name": "snap_to_grid_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "copilot_enabled_models": {
+ "name": "copilot_enabled_models",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "copilot_auto_allowed_tools": {
+ "name": "copilot_auto_allowed_tools",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "settings_user_id_user_id_fk": {
+ "name": "settings_user_id_user_id_fk",
+ "tableFrom": "settings",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "settings_user_id_unique": {
+ "name": "settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_provider": {
+ "name": "sso_provider",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "sso_provider_provider_id_idx": {
+ "name": "sso_provider_provider_id_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sso_provider_domain_idx": {
+ "name": "sso_provider_domain_idx",
+ "columns": [
+ {
+ "expression": "domain",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sso_provider_user_id_idx": {
+ "name": "sso_provider_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sso_provider_organization_id_idx": {
+ "name": "sso_provider_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sso_provider_user_id_user_id_fk": {
+ "name": "sso_provider_user_id_user_id_fk",
+ "tableFrom": "sso_provider",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sso_provider_organization_id_organization_id_fk": {
+ "name": "sso_provider_organization_id_organization_id_fk",
+ "tableFrom": "sso_provider",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.subscription": {
+ "name": "subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "plan": {
+ "name": "plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_start": {
+ "name": "period_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end": {
+ "name": "period_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_at_period_end": {
+ "name": "cancel_at_period_end",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trial_start": {
+ "name": "trial_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trial_end": {
+ "name": "trial_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "subscription_reference_status_idx": {
+ "name": "subscription_reference_status_idx",
+ "columns": [
+ {
+ "expression": "reference_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "check_enterprise_metadata": {
+ "name": "check_enterprise_metadata",
+ "value": "plan != 'enterprise' OR metadata IS NOT NULL"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.template_creators": {
+ "name": "template_creators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reference_type": {
+ "name": "reference_type",
+ "type": "template_creator_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_image_url": {
+ "name": "profile_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "template_creators_reference_idx": {
+ "name": "template_creators_reference_idx",
+ "columns": [
+ {
+ "expression": "reference_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "reference_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_creators_reference_id_idx": {
+ "name": "template_creators_reference_id_idx",
+ "columns": [
+ {
+ "expression": "reference_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_creators_created_by_idx": {
+ "name": "template_creators_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "template_creators_created_by_user_id_fk": {
+ "name": "template_creators_created_by_user_id_fk",
+ "tableFrom": "template_creators",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.template_stars": {
+ "name": "template_stars",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starred_at": {
+ "name": "starred_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "template_stars_user_id_idx": {
+ "name": "template_stars_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_template_id_idx": {
+ "name": "template_stars_template_id_idx",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_user_template_idx": {
+ "name": "template_stars_user_template_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_template_user_idx": {
+ "name": "template_stars_template_user_idx",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_starred_at_idx": {
+ "name": "template_stars_starred_at_idx",
+ "columns": [
+ {
+ "expression": "starred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_template_starred_at_idx": {
+ "name": "template_stars_template_starred_at_idx",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "template_stars_user_template_unique": {
+ "name": "template_stars_user_template_unique",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "template_stars_user_id_user_id_fk": {
+ "name": "template_stars_user_id_user_id_fk",
+ "tableFrom": "template_stars",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "template_stars_template_id_templates_id_fk": {
+ "name": "template_stars_template_id_templates_id_fk",
+ "tableFrom": "template_stars",
+ "tableTo": "templates",
+ "columnsFrom": ["template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.templates": {
+ "name": "templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "stars": {
+ "name": "stars",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "template_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "required_credentials": {
+ "name": "required_credentials",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "state": {
+ "name": "state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "og_image_url": {
+ "name": "og_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "templates_status_idx": {
+ "name": "templates_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_creator_id_idx": {
+ "name": "templates_creator_id_idx",
+ "columns": [
+ {
+ "expression": "creator_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_views_idx": {
+ "name": "templates_views_idx",
+ "columns": [
+ {
+ "expression": "views",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_stars_idx": {
+ "name": "templates_stars_idx",
+ "columns": [
+ {
+ "expression": "stars",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_status_views_idx": {
+ "name": "templates_status_views_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "views",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_status_stars_idx": {
+ "name": "templates_status_stars_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stars",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_created_at_idx": {
+ "name": "templates_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "templates_updated_at_idx": {
+ "name": "templates_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "templates_workflow_id_workflow_id_fk": {
+ "name": "templates_workflow_id_workflow_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "templates_creator_id_template_creators_id_fk": {
+ "name": "templates_creator_id_template_creators_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "template_creators",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.usage_log": {
+ "name": "usage_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "usage_log_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "usage_log_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_id": {
+ "name": "execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "usage_log_user_created_at_idx": {
+ "name": "usage_log_user_created_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "usage_log_source_idx": {
+ "name": "usage_log_source_idx",
+ "columns": [
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "usage_log_workspace_id_idx": {
+ "name": "usage_log_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "usage_log_workflow_id_idx": {
+ "name": "usage_log_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "usage_log_user_id_user_id_fk": {
+ "name": "usage_log_user_id_user_id_fk",
+ "tableFrom": "usage_log",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "usage_log_workspace_id_workspace_id_fk": {
+ "name": "usage_log_workspace_id_workspace_id_fk",
+ "tableFrom": "usage_log",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "usage_log_workflow_id_workflow_id_fk": {
+ "name": "usage_log_workflow_id_workflow_id_fk",
+ "tableFrom": "usage_log",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_super_user": {
+ "name": "is_super_user",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_stats": {
+ "name": "user_stats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_manual_executions": {
+ "name": "total_manual_executions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_api_calls": {
+ "name": "total_api_calls",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_webhook_triggers": {
+ "name": "total_webhook_triggers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_scheduled_executions": {
+ "name": "total_scheduled_executions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_chat_executions": {
+ "name": "total_chat_executions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_tokens_used": {
+ "name": "total_tokens_used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_cost": {
+ "name": "total_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "current_usage_limit": {
+ "name": "current_usage_limit",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'20'"
+ },
+ "usage_limit_updated_at": {
+ "name": "usage_limit_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "current_period_cost": {
+ "name": "current_period_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "last_period_cost": {
+ "name": "last_period_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "billed_overage_this_period": {
+ "name": "billed_overage_this_period",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "pro_period_cost_snapshot": {
+ "name": "pro_period_cost_snapshot",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "credit_balance": {
+ "name": "credit_balance",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "total_copilot_cost": {
+ "name": "total_copilot_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "current_period_copilot_cost": {
+ "name": "current_period_copilot_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "last_period_copilot_cost": {
+ "name": "last_period_copilot_cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "total_copilot_tokens": {
+ "name": "total_copilot_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_copilot_calls": {
+ "name": "total_copilot_calls",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "storage_used_bytes": {
+ "name": "storage_used_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_active": {
+ "name": "last_active",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "billing_blocked": {
+ "name": "billing_blocked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "billing_blocked_reason": {
+ "name": "billing_blocked_reason",
+ "type": "billing_blocked_reason",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_stats_user_id_user_id_fk": {
+ "name": "user_stats_user_id_user_id_fk",
+ "tableFrom": "user_stats",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_stats_user_id_unique": {
+ "name": "user_stats_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "verification_expires_at_idx": {
+ "name": "verification_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.waitlist": {
+ "name": "waitlist",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "waitlist_email_unique": {
+ "name": "waitlist_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook": {
+ "name": "webhook",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "block_id": {
+ "name": "block_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_config": {
+ "name": "provider_config",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "failed_count": {
+ "name": "failed_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "last_failed_at": {
+ "name": "last_failed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_set_id": {
+ "name": "credential_set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "path_idx": {
+ "name": "path_idx",
+ "columns": [
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_webhook_on_workflow_id_block_id": {
+ "name": "idx_webhook_on_workflow_id_block_id",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "block_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "webhook_credential_set_id_idx": {
+ "name": "webhook_credential_set_id_idx",
+ "columns": [
+ {
+ "expression": "credential_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "webhook_workflow_id_workflow_id_fk": {
+ "name": "webhook_workflow_id_workflow_id_fk",
+ "tableFrom": "webhook",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "webhook_block_id_workflow_blocks_id_fk": {
+ "name": "webhook_block_id_workflow_blocks_id_fk",
+ "tableFrom": "webhook",
+ "tableTo": "workflow_blocks",
+ "columnsFrom": ["block_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "webhook_credential_set_id_credential_set_id_fk": {
+ "name": "webhook_credential_set_id_credential_set_id_fk",
+ "tableFrom": "webhook",
+ "tableTo": "credential_set",
+ "columnsFrom": ["credential_set_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow": {
+ "name": "workflow",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "folder_id": {
+ "name": "folder_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#3972F6'"
+ },
+ "last_synced": {
+ "name": "last_synced",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_deployed": {
+ "name": "is_deployed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "deployed_at": {
+ "name": "deployed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_count": {
+ "name": "run_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ }
+ },
+ "indexes": {
+ "workflow_user_id_idx": {
+ "name": "workflow_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_workspace_id_idx": {
+ "name": "workflow_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_user_workspace_idx": {
+ "name": "workflow_user_workspace_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_user_id_user_id_fk": {
+ "name": "workflow_user_id_user_id_fk",
+ "tableFrom": "workflow",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_workspace_id_workspace_id_fk": {
+ "name": "workflow_workspace_id_workspace_id_fk",
+ "tableFrom": "workflow",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_folder_id_workflow_folder_id_fk": {
+ "name": "workflow_folder_id_workflow_folder_id_fk",
+ "tableFrom": "workflow",
+ "tableTo": "workflow_folder",
+ "columnsFrom": ["folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_blocks": {
+ "name": "workflow_blocks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_x": {
+ "name": "position_x",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_y": {
+ "name": "position_y",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "horizontal_handles": {
+ "name": "horizontal_handles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_wide": {
+ "name": "is_wide",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "advanced_mode": {
+ "name": "advanced_mode",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "trigger_mode": {
+ "name": "trigger_mode",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "height": {
+ "name": "height",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "sub_blocks": {
+ "name": "sub_blocks",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "outputs": {
+ "name": "outputs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_blocks_workflow_id_idx": {
+ "name": "workflow_blocks_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_blocks_type_idx": {
+ "name": "workflow_blocks_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_blocks_workflow_id_workflow_id_fk": {
+ "name": "workflow_blocks_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_blocks",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_checkpoints": {
+ "name": "workflow_checkpoints",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow_state": {
+ "name": "workflow_state",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_checkpoints_user_id_idx": {
+ "name": "workflow_checkpoints_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_workflow_id_idx": {
+ "name": "workflow_checkpoints_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_chat_id_idx": {
+ "name": "workflow_checkpoints_chat_id_idx",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_message_id_idx": {
+ "name": "workflow_checkpoints_message_id_idx",
+ "columns": [
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_user_workflow_idx": {
+ "name": "workflow_checkpoints_user_workflow_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_workflow_chat_idx": {
+ "name": "workflow_checkpoints_workflow_chat_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_created_at_idx": {
+ "name": "workflow_checkpoints_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_checkpoints_chat_created_at_idx": {
+ "name": "workflow_checkpoints_chat_created_at_idx",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_checkpoints_user_id_user_id_fk": {
+ "name": "workflow_checkpoints_user_id_user_id_fk",
+ "tableFrom": "workflow_checkpoints",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_checkpoints_workflow_id_workflow_id_fk": {
+ "name": "workflow_checkpoints_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_checkpoints",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_checkpoints_chat_id_copilot_chats_id_fk": {
+ "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk",
+ "tableFrom": "workflow_checkpoints",
+ "tableTo": "copilot_chats",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_deployment_version": {
+ "name": "workflow_deployment_version",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "workflow_deployment_version_workflow_version_unique": {
+ "name": "workflow_deployment_version_workflow_version_unique",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_deployment_version_workflow_active_idx": {
+ "name": "workflow_deployment_version_workflow_active_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_deployment_version_created_at_idx": {
+ "name": "workflow_deployment_version_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_deployment_version_workflow_id_workflow_id_fk": {
+ "name": "workflow_deployment_version_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_deployment_version",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_edges": {
+ "name": "workflow_edges",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_block_id": {
+ "name": "source_block_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_block_id": {
+ "name": "target_block_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_handle": {
+ "name": "source_handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_handle": {
+ "name": "target_handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_edges_workflow_id_idx": {
+ "name": "workflow_edges_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_edges_workflow_source_idx": {
+ "name": "workflow_edges_workflow_source_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_block_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_edges_workflow_target_idx": {
+ "name": "workflow_edges_workflow_target_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_block_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_edges_workflow_id_workflow_id_fk": {
+ "name": "workflow_edges_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_edges",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_edges_source_block_id_workflow_blocks_id_fk": {
+ "name": "workflow_edges_source_block_id_workflow_blocks_id_fk",
+ "tableFrom": "workflow_edges",
+ "tableTo": "workflow_blocks",
+ "columnsFrom": ["source_block_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_edges_target_block_id_workflow_blocks_id_fk": {
+ "name": "workflow_edges_target_block_id_workflow_blocks_id_fk",
+ "tableFrom": "workflow_edges",
+ "tableTo": "workflow_blocks",
+ "columnsFrom": ["target_block_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_execution_logs": {
+ "name": "workflow_execution_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_id": {
+ "name": "execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state_snapshot_id": {
+ "name": "state_snapshot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deployment_version_id": {
+ "name": "deployment_version_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "level": {
+ "name": "level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_duration_ms": {
+ "name": "total_duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_data": {
+ "name": "execution_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "cost": {
+ "name": "cost",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "files": {
+ "name": "files",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_execution_logs_workflow_id_idx": {
+ "name": "workflow_execution_logs_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_state_snapshot_id_idx": {
+ "name": "workflow_execution_logs_state_snapshot_id_idx",
+ "columns": [
+ {
+ "expression": "state_snapshot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_deployment_version_id_idx": {
+ "name": "workflow_execution_logs_deployment_version_id_idx",
+ "columns": [
+ {
+ "expression": "deployment_version_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_trigger_idx": {
+ "name": "workflow_execution_logs_trigger_idx",
+ "columns": [
+ {
+ "expression": "trigger",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_level_idx": {
+ "name": "workflow_execution_logs_level_idx",
+ "columns": [
+ {
+ "expression": "level",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_started_at_idx": {
+ "name": "workflow_execution_logs_started_at_idx",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_execution_id_unique": {
+ "name": "workflow_execution_logs_execution_id_unique",
+ "columns": [
+ {
+ "expression": "execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_workflow_started_at_idx": {
+ "name": "workflow_execution_logs_workflow_started_at_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_execution_logs_workspace_started_at_idx": {
+ "name": "workflow_execution_logs_workspace_started_at_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_execution_logs_workflow_id_workflow_id_fk": {
+ "name": "workflow_execution_logs_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_execution_logs",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_execution_logs_workspace_id_workspace_id_fk": {
+ "name": "workflow_execution_logs_workspace_id_workspace_id_fk",
+ "tableFrom": "workflow_execution_logs",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": {
+ "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk",
+ "tableFrom": "workflow_execution_logs",
+ "tableTo": "workflow_execution_snapshots",
+ "columnsFrom": ["state_snapshot_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": {
+ "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk",
+ "tableFrom": "workflow_execution_logs",
+ "tableTo": "workflow_deployment_version",
+ "columnsFrom": ["deployment_version_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_execution_snapshots": {
+ "name": "workflow_execution_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state_hash": {
+ "name": "state_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state_data": {
+ "name": "state_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_snapshots_workflow_id_idx": {
+ "name": "workflow_snapshots_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_snapshots_hash_idx": {
+ "name": "workflow_snapshots_hash_idx",
+ "columns": [
+ {
+ "expression": "state_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_snapshots_workflow_hash_idx": {
+ "name": "workflow_snapshots_workflow_hash_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_snapshots_created_at_idx": {
+ "name": "workflow_snapshots_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_execution_snapshots_workflow_id_workflow_id_fk": {
+ "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_execution_snapshots",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_folder": {
+ "name": "workflow_folder",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'#6B7280'"
+ },
+ "is_expanded": {
+ "name": "is_expanded",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_folder_user_idx": {
+ "name": "workflow_folder_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_folder_workspace_parent_idx": {
+ "name": "workflow_folder_workspace_parent_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_folder_parent_sort_idx": {
+ "name": "workflow_folder_parent_sort_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_folder_user_id_user_id_fk": {
+ "name": "workflow_folder_user_id_user_id_fk",
+ "tableFrom": "workflow_folder",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_folder_workspace_id_workspace_id_fk": {
+ "name": "workflow_folder_workspace_id_workspace_id_fk",
+ "tableFrom": "workflow_folder",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_mcp_server": {
+ "name": "workflow_mcp_server",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_mcp_server_workspace_id_idx": {
+ "name": "workflow_mcp_server_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_mcp_server_created_by_idx": {
+ "name": "workflow_mcp_server_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_mcp_server_workspace_id_workspace_id_fk": {
+ "name": "workflow_mcp_server_workspace_id_workspace_id_fk",
+ "tableFrom": "workflow_mcp_server",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_mcp_server_created_by_user_id_fk": {
+ "name": "workflow_mcp_server_created_by_user_id_fk",
+ "tableFrom": "workflow_mcp_server",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_mcp_tool": {
+ "name": "workflow_mcp_tool",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_description": {
+ "name": "tool_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parameter_schema": {
+ "name": "parameter_schema",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_mcp_tool_server_id_idx": {
+ "name": "workflow_mcp_tool_server_id_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_mcp_tool_workflow_id_idx": {
+ "name": "workflow_mcp_tool_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_mcp_tool_server_workflow_unique": {
+ "name": "workflow_mcp_tool_server_workflow_unique",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": {
+ "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk",
+ "tableFrom": "workflow_mcp_tool",
+ "tableTo": "workflow_mcp_server",
+ "columnsFrom": ["server_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_mcp_tool_workflow_id_workflow_id_fk": {
+ "name": "workflow_mcp_tool_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_mcp_tool",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_schedule": {
+ "name": "workflow_schedule",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "block_id": {
+ "name": "block_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_ran_at": {
+ "name": "last_ran_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_queued_at": {
+ "name": "last_queued_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trigger_type": {
+ "name": "trigger_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "failed_count": {
+ "name": "failed_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "last_failed_at": {
+ "name": "last_failed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_schedule_workflow_block_unique": {
+ "name": "workflow_schedule_workflow_block_unique",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "block_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_schedule_workflow_id_workflow_id_fk": {
+ "name": "workflow_schedule_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_schedule",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workflow_schedule_block_id_workflow_blocks_id_fk": {
+ "name": "workflow_schedule_block_id_workflow_blocks_id_fk",
+ "tableFrom": "workflow_schedule",
+ "tableTo": "workflow_blocks",
+ "columnsFrom": ["block_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workflow_subflows": {
+ "name": "workflow_subflows",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workflow_subflows_workflow_id_idx": {
+ "name": "workflow_subflows_workflow_id_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workflow_subflows_workflow_type_idx": {
+ "name": "workflow_subflows_workflow_type_idx",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workflow_subflows_workflow_id_workflow_id_fk": {
+ "name": "workflow_subflows_workflow_id_workflow_id_fk",
+ "tableFrom": "workflow_subflows",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace": {
+ "name": "workspace",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "billed_account_user_id": {
+ "name": "billed_account_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow_personal_api_keys": {
+ "name": "allow_personal_api_keys",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "workspace_owner_id_user_id_fk": {
+ "name": "workspace_owner_id_user_id_fk",
+ "tableFrom": "workspace",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_billed_account_user_id_user_id_fk": {
+ "name": "workspace_billed_account_user_id_user_id_fk",
+ "tableFrom": "workspace",
+ "tableTo": "user",
+ "columnsFrom": ["billed_account_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_byok_keys": {
+ "name": "workspace_byok_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_api_key": {
+ "name": "encrypted_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_byok_provider_unique": {
+ "name": "workspace_byok_provider_unique",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_byok_workspace_idx": {
+ "name": "workspace_byok_workspace_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_byok_keys_workspace_id_workspace_id_fk": {
+ "name": "workspace_byok_keys_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_byok_keys",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_byok_keys_created_by_user_id_fk": {
+ "name": "workspace_byok_keys_created_by_user_id_fk",
+ "tableFrom": "workspace_byok_keys",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_environment": {
+ "name": "workspace_environment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variables": {
+ "name": "variables",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_environment_workspace_unique": {
+ "name": "workspace_environment_workspace_unique",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_environment_workspace_id_workspace_id_fk": {
+ "name": "workspace_environment_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_environment",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_file": {
+ "name": "workspace_file",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_file_workspace_id_idx": {
+ "name": "workspace_file_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_file_key_idx": {
+ "name": "workspace_file_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_file_workspace_id_workspace_id_fk": {
+ "name": "workspace_file_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_file",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_file_uploaded_by_user_id_fk": {
+ "name": "workspace_file_uploaded_by_user_id_fk",
+ "tableFrom": "workspace_file",
+ "tableTo": "user",
+ "columnsFrom": ["uploaded_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_file_key_unique": {
+ "name": "workspace_file_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_files": {
+ "name": "workspace_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "context": {
+ "name": "context",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_name": {
+ "name": "original_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_files_key_idx": {
+ "name": "workspace_files_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_files_user_id_idx": {
+ "name": "workspace_files_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_files_workspace_id_idx": {
+ "name": "workspace_files_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_files_context_idx": {
+ "name": "workspace_files_context_idx",
+ "columns": [
+ {
+ "expression": "context",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_files_user_id_user_id_fk": {
+ "name": "workspace_files_user_id_user_id_fk",
+ "tableFrom": "workspace_files",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_files_workspace_id_workspace_id_fk": {
+ "name": "workspace_files_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_files",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_files_key_unique": {
+ "name": "workspace_files_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_invitation": {
+ "name": "workspace_invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "status": {
+ "name": "status",
+ "type": "workspace_invitation_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "permission_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'admin'"
+ },
+ "org_invitation_id": {
+ "name": "org_invitation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "workspace_invitation_workspace_id_workspace_id_fk": {
+ "name": "workspace_invitation_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_invitation",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_invitation_inviter_id_user_id_fk": {
+ "name": "workspace_invitation_inviter_id_user_id_fk",
+ "tableFrom": "workspace_invitation",
+ "tableTo": "user",
+ "columnsFrom": ["inviter_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspace_invitation_token_unique": {
+ "name": "workspace_invitation_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_notification_delivery": {
+ "name": "workspace_notification_delivery",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_id": {
+ "name": "execution_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "notification_delivery_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_status": {
+ "name": "response_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_body": {
+ "name": "response_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_notification_delivery_subscription_id_idx": {
+ "name": "workspace_notification_delivery_subscription_id_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_notification_delivery_execution_id_idx": {
+ "name": "workspace_notification_delivery_execution_id_idx",
+ "columns": [
+ {
+ "expression": "execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_notification_delivery_status_idx": {
+ "name": "workspace_notification_delivery_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_notification_delivery_next_attempt_idx": {
+ "name": "workspace_notification_delivery_next_attempt_idx",
+ "columns": [
+ {
+ "expression": "next_attempt_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": {
+ "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk",
+ "tableFrom": "workspace_notification_delivery",
+ "tableTo": "workspace_notification_subscription",
+ "columnsFrom": ["subscription_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_notification_delivery_workflow_id_workflow_id_fk": {
+ "name": "workspace_notification_delivery_workflow_id_workflow_id_fk",
+ "tableFrom": "workspace_notification_delivery",
+ "tableTo": "workflow",
+ "columnsFrom": ["workflow_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_notification_subscription": {
+ "name": "workspace_notification_subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notification_type": {
+ "name": "notification_type",
+ "type": "notification_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_ids": {
+ "name": "workflow_ids",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "all_workflows": {
+ "name": "all_workflows",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "level_filter": {
+ "name": "level_filter",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY['info', 'error']::text[]"
+ },
+ "trigger_filter": {
+ "name": "trigger_filter",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]"
+ },
+ "include_final_output": {
+ "name": "include_final_output",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "include_trace_spans": {
+ "name": "include_trace_spans",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "include_rate_limits": {
+ "name": "include_rate_limits",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "include_usage_data": {
+ "name": "include_usage_data",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "webhook_config": {
+ "name": "webhook_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_recipients": {
+ "name": "email_recipients",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slack_config": {
+ "name": "slack_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "alert_config": {
+ "name": "alert_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_alert_at": {
+ "name": "last_alert_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_notification_workspace_id_idx": {
+ "name": "workspace_notification_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_notification_active_idx": {
+ "name": "workspace_notification_active_idx",
+ "columns": [
+ {
+ "expression": "active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_notification_type_idx": {
+ "name": "workspace_notification_type_idx",
+ "columns": [
+ {
+ "expression": "notification_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_notification_subscription_workspace_id_workspace_id_fk": {
+ "name": "workspace_notification_subscription_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_notification_subscription",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_notification_subscription_created_by_user_id_fk": {
+ "name": "workspace_notification_subscription_created_by_user_id_fk",
+ "tableFrom": "workspace_notification_subscription",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.billing_blocked_reason": {
+ "name": "billing_blocked_reason",
+ "schema": "public",
+ "values": ["payment_failed", "dispute"]
+ },
+ "public.credential_set_invitation_status": {
+ "name": "credential_set_invitation_status",
+ "schema": "public",
+ "values": ["pending", "accepted", "expired", "cancelled"]
+ },
+ "public.credential_set_member_status": {
+ "name": "credential_set_member_status",
+ "schema": "public",
+ "values": ["active", "pending", "revoked"]
+ },
+ "public.notification_delivery_status": {
+ "name": "notification_delivery_status",
+ "schema": "public",
+ "values": ["pending", "in_progress", "success", "failed"]
+ },
+ "public.notification_type": {
+ "name": "notification_type",
+ "schema": "public",
+ "values": ["webhook", "email", "slack"]
+ },
+ "public.permission_type": {
+ "name": "permission_type",
+ "schema": "public",
+ "values": ["admin", "write", "read"]
+ },
+ "public.template_creator_type": {
+ "name": "template_creator_type",
+ "schema": "public",
+ "values": ["user", "organization"]
+ },
+ "public.template_status": {
+ "name": "template_status",
+ "schema": "public",
+ "values": ["pending", "approved", "rejected"]
+ },
+ "public.usage_log_category": {
+ "name": "usage_log_category",
+ "schema": "public",
+ "values": ["model", "fixed"]
+ },
+ "public.usage_log_source": {
+ "name": "usage_log_source",
+ "schema": "public",
+ "values": ["workflow", "wand", "copilot"]
+ },
+ "public.workspace_invitation_status": {
+ "name": "workspace_invitation_status",
+ "schema": "public",
+ "values": ["pending", "accepted", "rejected", "cancelled"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index 26a68ca9ab..591a6db7fd 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -939,6 +939,13 @@
"when": 1766779827389,
"tag": "0134_parallel_galactus",
"breakpoints": true
+ },
+ {
+ "idx": 135,
+ "version": "7",
+ "when": 1767737974016,
+ "tag": "0135_stormy_puff_adder",
+ "breakpoints": true
}
]
}
From 68fc8643aade817f599f161093d2038a3b88d1eb Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 15:14:16 -0800
Subject: [PATCH 09/21] fix types
---
apps/sim/tools/types.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts
index 544a9c83e5..f27574de16 100644
--- a/apps/sim/tools/types.ts
+++ b/apps/sim/tools/types.ts
@@ -93,7 +93,7 @@ export interface ToolConfig {
url: string | ((params: P) => string)
method: HttpMethod | ((params: P) => HttpMethod)
headers: (params: P) => Record
- body?: (params: P) => Record | string
+ body?: (params: P) => Record | string | undefined
}
// Post-processing (optional) - allows additional processing after the initial request
From 3f67a7c13f1d5374077e34a14cb59d5a930f6fa2 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 19:02:20 -0800
Subject: [PATCH 10/21] fix
---
.../sim/app/api/workflows/[id]/state/route.ts | 63 ++++++++++++++++++-
apps/sim/hooks/use-webhook-management.ts | 6 +-
2 files changed, 62 insertions(+), 7 deletions(-)
diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts
index 5995182ad6..a0162e0e78 100644
--- a/apps/sim/app/api/workflows/[id]/state/route.ts
+++ b/apps/sim/app/api/workflows/[id]/state/route.ts
@@ -317,6 +317,8 @@ interface WebhookMetadata {
providerConfig: Record
}
+const CREDENTIAL_SET_PREFIX = 'credentialSet:'
+
function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerId =
getSubBlockValue(block, 'triggerId') ||
@@ -328,9 +330,17 @@ function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerDef = triggerId ? getTrigger(triggerId) : undefined
const provider = triggerDef?.provider || null
+ // Handle credential sets vs individual credentials
+ const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX)
+ const credentialSetId = isCredentialSet
+ ? triggerCredentials.slice(CREDENTIAL_SET_PREFIX.length)
+ : undefined
+ const credentialId = isCredentialSet ? undefined : triggerCredentials
+
const providerConfig = {
...(typeof triggerConfig === 'object' ? triggerConfig : {}),
- ...(triggerCredentials ? { credentialId: triggerCredentials } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
...(triggerId ? { triggerId } : {}),
}
@@ -347,6 +357,54 @@ async function upsertWebhookRecord(
webhookId: string,
metadata: WebhookMetadata
): Promise {
+ const providerConfig = metadata.providerConfig as Record
+ const credentialSetId = providerConfig?.credentialSetId as string | undefined
+
+ // For credential sets, delegate to the sync function which handles fan-out
+ if (credentialSetId && metadata.provider) {
+ const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server')
+ const { getProviderIdFromServiceId } = await import('@/lib/oauth')
+
+ const oauthProviderId = getProviderIdFromServiceId(metadata.provider)
+ const requestId = crypto.randomUUID().slice(0, 8)
+
+ // Extract base config (without credential-specific fields)
+ const {
+ credentialId: _cId,
+ credentialSetId: _csId,
+ userId: _uId,
+ ...baseConfig
+ } = providerConfig
+
+ try {
+ await syncWebhooksForCredentialSet({
+ workflowId,
+ blockId: block.id,
+ provider: metadata.provider,
+ basePath: metadata.triggerPath,
+ credentialSetId,
+ oauthProviderId,
+ providerConfig: baseConfig as Record,
+ requestId,
+ })
+
+ logger.info('Synced credential set webhooks during workflow save', {
+ workflowId,
+ blockId: block.id,
+ credentialSetId,
+ })
+ } catch (error) {
+ logger.error('Failed to sync credential set webhooks during workflow save', {
+ workflowId,
+ blockId: block.id,
+ credentialSetId,
+ error,
+ })
+ }
+ return
+ }
+
+ // For individual credentials, use the existing single webhook logic
const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (existing) {
@@ -374,7 +432,6 @@ async function upsertWebhookRecord(
return
}
- const providerConfig = metadata.providerConfig as Record
await db.insert(webhook).values({
id: webhookId,
workflowId,
@@ -382,7 +439,7 @@ async function upsertWebhookRecord(
path: metadata.triggerPath,
provider: metadata.provider,
providerConfig: metadata.providerConfig,
- credentialSetId: (providerConfig?.credentialSetId as string | null) || null,
+ credentialSetId: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts
index e442658ee3..3861380c55 100644
--- a/apps/sim/hooks/use-webhook-management.ts
+++ b/apps/sim/hooks/use-webhook-management.ts
@@ -238,8 +238,7 @@ export function useWebhookManagement({
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
- const isCredentialSet =
- selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX)
+ const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
@@ -305,8 +304,7 @@ export function useWebhookManagement({
): Promise => {
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
- const isCredentialSet =
- selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX)
+ const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
From dbdd56d1f268ef6b9dec9d2bf459ca620704dbd6 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 19:13:18 -0800
Subject: [PATCH 11/21] fix icon for outlook
---
apps/sim/app/api/credential-sets/invitations/route.ts | 1 +
apps/sim/app/api/credential-sets/memberships/route.ts | 1 +
apps/sim/app/api/workflows/[id]/state/route.ts | 2 +-
.../components/credential-sets/credential-sets.tsx | 4 ++--
apps/sim/hooks/queries/credential-sets.ts | 2 ++
apps/sim/hooks/use-webhook-management.ts | 4 ++--
6 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts
index ca33bab7af..0a4df72315 100644
--- a/apps/sim/app/api/credential-sets/invitations/route.ts
+++ b/apps/sim/app/api/credential-sets/invitations/route.ts
@@ -24,6 +24,7 @@ export async function GET() {
createdAt: credentialSetInvitation.createdAt,
credentialSetId: credentialSet.id,
credentialSetName: credentialSet.name,
+ providerId: credentialSet.providerId,
organizationId: organization.id,
organizationName: organization.name,
invitedByName: user.name,
diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts
index aaaeecfc4f..5ce0384d4a 100644
--- a/apps/sim/app/api/credential-sets/memberships/route.ts
+++ b/apps/sim/app/api/credential-sets/memberships/route.ts
@@ -24,6 +24,7 @@ export async function GET() {
credentialSetId: credentialSet.id,
credentialSetName: credentialSet.name,
credentialSetDescription: credentialSet.description,
+ providerId: credentialSet.providerId,
organizationId: organization.id,
organizationName: organization.name,
})
diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts
index a0162e0e78..2cadeff341 100644
--- a/apps/sim/app/api/workflows/[id]/state/route.ts
+++ b/apps/sim/app/api/workflows/[id]/state/route.ts
@@ -333,7 +333,7 @@ function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
// Handle credential sets vs individual credentials
const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
- ? triggerCredentials.slice(CREDENTIAL_SET_PREFIX.length)
+ ? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : triggerCredentials
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 320e4c31f5..1848009a6e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -505,7 +505,7 @@ export function CredentialSets() {
-
+ {getProviderIcon(invitation.providerId)}
@@ -541,7 +541,7 @@ export function CredentialSets() {
-
+ {getProviderIcon(membership.providerId)}
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index 77e83d2435..99afdd1f03 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -21,6 +21,7 @@ export interface CredentialSetMembership {
credentialSetId: string
credentialSetName: string
credentialSetDescription: string | null
+ providerId: string | null
organizationId: string
organizationName: string
}
@@ -33,6 +34,7 @@ export interface CredentialSetInvitation {
createdAt: string
credentialSetId: string
credentialSetName: string
+ providerId: string | null
organizationId: string
organizationName: string
invitedByName: string | null
diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts
index 3861380c55..e71a0cedb3 100644
--- a/apps/sim/hooks/use-webhook-management.ts
+++ b/apps/sim/hooks/use-webhook-management.ts
@@ -240,7 +240,7 @@ export function useWebhookManagement({
const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
- ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
+ ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : selectedCredentialId
@@ -306,7 +306,7 @@ export function useWebhookManagement({
const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
- ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length)
+ ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : selectedCredentialId
From 677332cbdee05a94a8ca2a9eea47e56a899796b9 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 19:28:03 -0800
Subject: [PATCH 12/21] fix cred set name not showing up for owner
---
.../workflow-block/workflow-block.tsx | 3 ++-
apps/sim/hooks/queries/oauth-credentials.ts | 25 +++++++++++++++----
2 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index ecd51c7f57..f2671518fa 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -371,7 +371,8 @@ const SubBlockRow = ({
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
credentialProviderId,
- workflowId
+ workflowId,
+ workspaceId
)
const credentialId = dependencyValues.credential
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
index 89b9913eb6..859a7ba280 100644
--- a/apps/sim/hooks/queries/oauth-credentials.ts
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
import { CREDENTIAL_SET } from '@/executor/constants'
-import { useCredentialSetMemberships } from '@/hooks/queries/credential-sets'
+import { useCredentialSetMemberships, useCredentialSets } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
@@ -62,7 +62,12 @@ export function useOAuthCredentialDetail(
})
}
-export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
+export function useCredentialName(
+ credentialId?: string,
+ providerId?: string,
+ workflowId?: string,
+ workspaceId?: string
+) {
// Check if this is a credential set value
const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
const credentialSetId = isCredentialSet
@@ -72,6 +77,12 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
// Fetch credential set memberships if this is a credential set
const { data: memberships = [], isFetching: membershipsLoading } = useCredentialSetMemberships()
+ // Also fetch owned credential sets to check there
+ const { data: ownedSets = [], isFetching: ownedSetsLoading } = useCredentialSets(
+ workspaceId,
+ isCredentialSet && Boolean(workspaceId)
+ )
+
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
providerId,
Boolean(providerId) && !isCredentialSet
@@ -91,9 +102,10 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
const hasForeignMeta = foreignCredentials.length > 0
- // For credential sets, find the matching membership and use its name
+ // For credential sets, find the matching membership or owned set and use its name
const credentialSetName = credentialSetId
- ? memberships.find((m) => m.credentialSetId === credentialSetId)?.credentialSetName
+ ? (memberships.find((m) => m.credentialSetId === credentialSetId)?.credentialSetName ??
+ ownedSets.find((s) => s.id === credentialSetId)?.name)
: undefined
const displayName =
@@ -103,7 +115,10 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
return {
displayName,
- isLoading: credentialsLoading || foreignLoading || (isCredentialSet && membershipsLoading),
+ isLoading:
+ credentialsLoading ||
+ foreignLoading ||
+ (isCredentialSet && (membershipsLoading || ownedSetsLoading)),
hasForeignMeta,
}
}
From 3feb636b35ca3fba1f663cfb81b602469730e682 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 19:36:24 -0800
Subject: [PATCH 13/21] fix rendering of credential set name
---
.../workflow-block/workflow-block.tsx | 3 +-
apps/sim/hooks/queries/credential-sets.ts | 19 +++++++++++
apps/sim/hooks/queries/oauth-credentials.ts | 33 +++++--------------
3 files changed, 28 insertions(+), 27 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index f2671518fa..ecd51c7f57 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -371,8 +371,7 @@ const SubBlockRow = ({
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
credentialProviderId,
- workflowId,
- workspaceId
+ workflowId
)
const credentialId = dependencyValues.credential
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index 99afdd1f03..ec0da3f954 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -78,6 +78,25 @@ export function useCredentialSets(organizationId?: string, enabled = true) {
})
}
+interface CredentialSetDetailResponse {
+ credentialSet?: CredentialSet
+}
+
+export async function fetchCredentialSetById(id: string): Promise {
+ if (!id) return null
+ const data = await fetchJson(`/api/credential-sets/${id}`)
+ return data.credentialSet ?? null
+}
+
+export function useCredentialSetDetail(id?: string, enabled = true) {
+ return useQuery({
+ queryKey: credentialSetKeys.detail(id),
+ queryFn: () => fetchCredentialSetById(id ?? ''),
+ enabled: Boolean(id) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
export function useCredentialSetMemberships() {
return useQuery({
queryKey: credentialSetKeys.memberships(),
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
index 859a7ba280..650858b048 100644
--- a/apps/sim/hooks/queries/oauth-credentials.ts
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
import { CREDENTIAL_SET } from '@/executor/constants'
-import { useCredentialSetMemberships, useCredentialSets } from '@/hooks/queries/credential-sets'
+import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
@@ -62,25 +62,17 @@ export function useOAuthCredentialDetail(
})
}
-export function useCredentialName(
- credentialId?: string,
- providerId?: string,
- workflowId?: string,
- workspaceId?: string
-) {
+export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
// Check if this is a credential set value
const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
const credentialSetId = isCredentialSet
? credentialId?.slice(CREDENTIAL_SET.PREFIX.length)
: undefined
- // Fetch credential set memberships if this is a credential set
- const { data: memberships = [], isFetching: membershipsLoading } = useCredentialSetMemberships()
-
- // Also fetch owned credential sets to check there
- const { data: ownedSets = [], isFetching: ownedSetsLoading } = useCredentialSets(
- workspaceId,
- isCredentialSet && Boolean(workspaceId)
+ // Fetch credential set by ID directly
+ const { data: credentialSetData, isFetching: credentialSetLoading } = useCredentialSetDetail(
+ credentialSetId,
+ isCredentialSet
)
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
@@ -102,23 +94,14 @@ export function useCredentialName(
const hasForeignMeta = foreignCredentials.length > 0
- // For credential sets, find the matching membership or owned set and use its name
- const credentialSetName = credentialSetId
- ? (memberships.find((m) => m.credentialSetId === credentialSetId)?.credentialSetName ??
- ownedSets.find((s) => s.id === credentialSetId)?.name)
- : undefined
-
const displayName =
- credentialSetName ??
+ credentialSetData?.name ??
selectedCredential?.name ??
(hasForeignMeta ? 'Saved by collaborator' : null)
return {
displayName,
- isLoading:
- credentialsLoading ||
- foreignLoading ||
- (isCredentialSet && (membershipsLoading || ownedSetsLoading)),
+ isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading),
hasForeignMeta,
}
}
From fc88aff5d38731e36f809db21db4032964cdb100 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 6 Jan 2026 20:14:56 -0800
Subject: [PATCH 14/21] fix outlook well known folder id resolution
---
.../lib/webhooks/outlook-polling-service.ts | 105 +++++++++++++++++-
1 file changed, 101 insertions(+), 4 deletions(-)
diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts
index 68f93385ac..448067a4e2 100644
--- a/apps/sim/lib/webhooks/outlook-polling-service.ts
+++ b/apps/sim/lib/webhooks/outlook-polling-service.ts
@@ -359,7 +359,19 @@ async function fetchNewOutlookEmails(
const data = await response.json()
const emails = data.value || []
- const filteredEmails = filterEmailsByFolder(emails, config)
+ let resolvedFolderIds: Map | undefined
+ if (config.folderIds && config.folderIds.length > 0) {
+ const hasWellKnownFolders = config.folderIds.some(isWellKnownFolderName)
+ if (hasWellKnownFolders) {
+ resolvedFolderIds = await resolveWellKnownFolderIds(
+ accessToken,
+ config.folderIds,
+ requestId
+ )
+ }
+ }
+
+ const filteredEmails = filterEmailsByFolder(emails, config, resolvedFolderIds)
logger.info(
`[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering`
@@ -373,18 +385,103 @@ async function fetchNewOutlookEmails(
}
}
+const OUTLOOK_WELL_KNOWN_FOLDERS = new Set([
+ 'inbox',
+ 'drafts',
+ 'sentitems',
+ 'deleteditems',
+ 'junkemail',
+ 'archive',
+ 'outbox',
+])
+
+function isWellKnownFolderName(folderId: string): boolean {
+ return OUTLOOK_WELL_KNOWN_FOLDERS.has(folderId.toLowerCase())
+}
+
+async function resolveWellKnownFolderId(
+ accessToken: string,
+ folderName: string,
+ requestId: string
+): Promise {
+ try {
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ logger.warn(
+ `[${requestId}] Failed to resolve well-known folder '${folderName}': ${response.status}`
+ )
+ return null
+ }
+
+ const folder = await response.json()
+ return folder.id || null
+ } catch (error) {
+ logger.error(`[${requestId}] Error resolving well-known folder '${folderName}':`, error)
+ return null
+ }
+}
+
+async function resolveWellKnownFolderIds(
+ accessToken: string,
+ folderIds: string[],
+ requestId: string
+): Promise> {
+ const resolvedIds = new Map()
+
+ const wellKnownFolders = folderIds.filter(isWellKnownFolderName)
+ if (wellKnownFolders.length === 0) {
+ return resolvedIds
+ }
+
+ const resolutions = await Promise.all(
+ wellKnownFolders.map(async (folderName) => {
+ const actualId = await resolveWellKnownFolderId(accessToken, folderName, requestId)
+ return { folderName, actualId }
+ })
+ )
+
+ for (const { folderName, actualId } of resolutions) {
+ if (actualId) {
+ resolvedIds.set(folderName.toLowerCase(), actualId)
+ }
+ }
+
+ logger.info(
+ `[${requestId}] Resolved ${resolvedIds.size}/${wellKnownFolders.length} well-known folders`
+ )
+
+ return resolvedIds
+}
+
function filterEmailsByFolder(
emails: OutlookEmail[],
- config: OutlookWebhookConfig
+ config: OutlookWebhookConfig,
+ resolvedFolderIds?: Map
): OutlookEmail[] {
if (!config.folderIds || !config.folderIds.length) {
return emails
}
+ const actualFolderIds = config.folderIds.map((configFolder) => {
+ if (resolvedFolderIds && isWellKnownFolderName(configFolder)) {
+ const resolvedId = resolvedFolderIds.get(configFolder.toLowerCase())
+ if (resolvedId) {
+ return resolvedId
+ }
+ }
+ return configFolder
+ })
+
return emails.filter((email) => {
const emailFolderId = email.parentFolderId
- const hasMatchingFolder = config.folderIds!.some((configFolder) =>
- emailFolderId.toLowerCase().includes(configFolder.toLowerCase())
+ const hasMatchingFolder = actualFolderIds.some(
+ (folderId) => emailFolderId.toLowerCase() === folderId.toLowerCase()
)
return config.folderFilterBehavior === 'INCLUDE' ? hasMatchingFolder : !hasMatchingFolder
From 0f9338d6de9d1447eb42f6da1324a9382ebb4254 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 10:11:50 -0800
Subject: [PATCH 15/21] fix perms for creating cred set
---
apps/sim/app/api/credential-sets/[id]/invite/route.ts | 8 ++++----
apps/sim/app/api/credential-sets/[id]/members/route.ts | 4 ++--
apps/sim/app/api/credential-sets/[id]/route.ts | 8 ++++----
apps/sim/app/api/credential-sets/route.ts | 5 +++--
4 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
index 239de4456e..59b34a8268 100644
--- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
@@ -78,8 +78,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
- if (result.role !== 'admin') {
- return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
@@ -193,8 +193,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
- if (result.role !== 'admin') {
- return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
index f7cc55e8d0..f8fb0b2f08 100644
--- a/apps/sim/app/api/credential-sets/[id]/members/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -125,8 +125,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
- if (result.role !== 'admin') {
- return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const [memberToRemove] = await db
diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts
index 730966e19d..26af42e705 100644
--- a/apps/sim/app/api/credential-sets/[id]/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/route.ts
@@ -75,8 +75,8 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
- if (result.role !== 'admin') {
- return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
@@ -138,8 +138,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
- if (result.role !== 'admin') {
- return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id))
diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts
index 19f6521b91..51d80293d8 100644
--- a/apps/sim/app/api/credential-sets/route.ts
+++ b/apps/sim/app/api/credential-sets/route.ts
@@ -95,9 +95,10 @@ export async function POST(req: Request) {
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
- if (membership.length === 0 || membership[0].role !== 'admin') {
+ const role = membership[0]?.role
+ if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
return NextResponse.json(
- { error: 'Admin permissions required to create credential sets' },
+ { error: 'Admin or owner permissions required to create credential sets' },
{ status: 403 }
)
}
From 34f4d15300188285ba887f22bb7aae5a1e187e65 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 11:26:59 -0800
Subject: [PATCH 16/21] add to docs and simplify ui
---
apps/docs/content/docs/en/triggers/index.mdx | 25 ++
.../credential-sets/credential-sets.tsx | 423 +++++++++---------
2 files changed, 241 insertions(+), 207 deletions(-)
diff --git a/apps/docs/content/docs/en/triggers/index.mdx b/apps/docs/content/docs/en/triggers/index.mdx
index 8ac76ad4b6..2a78dd1b33 100644
--- a/apps/docs/content/docs/en/triggers/index.mdx
+++ b/apps/docs/content/docs/en/triggers/index.mdx
@@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
Monitor RSS and Atom feeds for new content
+
+ Monitor team Gmail and Outlook inboxes
+
## Quick Comparison
@@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
+| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
@@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe
**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.
+## Email Polling Groups
+
+Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan.
+
+**Creating a Polling Group** (Admin/Owner)
+
+1. Go to **Settings → Email Polling**
+2. Click **Create** and choose Gmail or Outlook
+3. Enter a name for the group
+
+**Inviting Members**
+
+1. Click **Add Members** on your polling group
+2. Enter email addresses (comma or newline separated, or drag & drop a CSV)
+3. Click **Send Invites**
+
+Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization.
+
+**Using in a Workflow**
+
+When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 1848009a6e..4ded0cb733 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -7,6 +7,7 @@ import {
Avatar,
AvatarFallback,
AvatarImage,
+ Badge,
Button,
Input,
Label,
@@ -318,12 +319,7 @@ export function CredentialSets() {
{getProviderIcon(viewingSet.providerId)}
-
- {viewingSet.name}
-
- {getProviderDisplayName(viewingSet.providerId || '')}
-
-
+ {viewingSet.name}
-
-
- {membersLoading ? (
-
-
- {[1, 2].map((i) => (
-
-
-
-
-
-
-
+
+ {membersLoading ? (
+
+
+ {[1, 2].map((i) => (
+
- ) : (
- <>
- {activeMembers.length > 0 && (
-
-
- Active Members ({activeMembers.length})
-
- {activeMembers.map((member) => (
-
-
-
-
-
-
-
-
-
+
+
+ ))}
+
+ ) : members.length === 0 ? (
+
+ No members yet
+
+ ) : (
+
+ {activeMembers.length > 0 && (
+
+
+ Active Members ({activeMembers.length})
+
+ {activeMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
{member.userName || 'Unknown'}
-
- {member.userEmail}
-
+ {member.credentials.length === 0 && (
+
+ Disconnected
+
+ )}
+
+ {member.userEmail}
+
-
handleRemoveMember(member.id)}
- disabled={removeMember.isPending}
- >
- Remove
-
- ))}
-
- )}
-
- {pendingMembers.length > 0 && (
-
-
- Pending ({pendingMembers.length})
+ handleRemoveMember(member.id)}
+ disabled={removeMember.isPending}
+ >
+ Remove
+
- {pendingMembers.map((member) => (
-
-
-
-
-
-
-
-
-
-
- {member.userName || 'Unknown'}
-
-
- {member.userEmail}
-
-
+ ))}
+
+ )}
+
+ {pendingMembers.length > 0 && (
+
+
+ Pending ({pendingMembers.length})
+
+ {pendingMembers.map((member) => (
+
+
+
+
+
+
+
+
+
+
+ {member.userName || 'Unknown'}
+
+
+ {member.userEmail}
+
-
Pending
- ))}
-
- )}
-
- {members.length === 0 && (
-
No members yet
- )}
- >
- )}
-
+
Pending
+
+ ))}
+
+ )}
+
+ )}
@@ -487,147 +488,155 @@ export function CredentialSets() {
return (
<>
-
-
- {hasNoContent && !canManageCredentialSets && (
-
- You're not a member of any polling groups yet. When someone invites you, it will
- appear here.
-
- )}
-
- {invitations.length > 0 && (
-
-
- Pending Invitations
-
- {invitations.map((invitation) => (
-
-
-
- {getProviderIcon(invitation.providerId)}
-
-
-
- {invitation.credentialSetName}
-
-
- {invitation.organizationName}
-
-
-
-
handleAcceptInvitation(invitation.token)}
- disabled={acceptInvitation.isPending}
- >
- {acceptInvitation.isPending ? (
-
- ) : (
- 'Accept'
- )}
-
+
+ {hasNoContent && !canManageCredentialSets ? (
+
+ You're not a member of any polling groups yet. When someone invites you, it will
+ appear here.
+
+ ) : (
+
+ {invitations.length > 0 && (
+
+
+ Pending Invitations
- ))}
-
- )}
-
- {activeMemberships.length > 0 && (
-
-
- My Memberships
-
- {activeMemberships.map((membership) => (
-
-
-
- {getProviderIcon(membership.providerId)}
-
-
-
- {membership.credentialSetName}
-
-
- {membership.organizationName}
-
-
-
-
- handleLeave(membership.credentialSetId, membership.credentialSetName)
- }
- disabled={leaveCredentialSet.isPending}
+ {invitations.map((invitation) => (
+
- Leave
-
-
- ))}
-
- )}
-
- {canManageCredentialSets && (
-
-
-
Manage
-
setShowCreateModal(true)}>
-
- Create
-
-
- {ownedSetsLoading ? (
- <>
- {[1, 2].map((i) => (
-
-
-
-
-
-
-
+
+
+ {getProviderIcon(invitation.providerId)}
+
+
+
+ {invitation.credentialSetName}
+
+
+ {invitation.organizationName}
+
-
- ))}
- >
- ) : ownedSets.length === 0 ? (
-
- No polling groups created yet
+ handleAcceptInvitation(invitation.token)}
+ disabled={acceptInvitation.isPending}
+ >
+ {acceptInvitation.isPending ? (
+
+ ) : (
+ 'Accept'
+ )}
+
+
+ ))}
+
+ )}
+
+ {activeMemberships.length > 0 && (
+
+
+ My Memberships
- ) : (
- ownedSets.map((set) => (
+ {activeMemberships.map((membership) => (
setViewingSet(set)}
+ key={membership.membershipId}
+ className='flex items-center justify-between'
>
- {getProviderIcon(set.providerId)}
+ {getProviderIcon(membership.providerId)}
- {set.name}
+
+ {membership.credentialSetName}
+
- {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+ {membership.organizationName}
{
- e.stopPropagation()
- setSelectedSetId(set.id)
- setShowInviteModal(true)
- }}
+ variant='ghost'
+ onClick={() =>
+ handleLeave(membership.credentialSetId, membership.credentialSetName)
+ }
+ disabled={leaveCredentialSet.isPending}
>
- Add Members
+ Leave
- ))
- )}
-
- )}
-
+ ))}
+
+ )}
+
+ {canManageCredentialSets && (
+
+
+
+ Manage
+
+
setShowCreateModal(true)}>
+
+ Create
+
+
+ {ownedSetsLoading ? (
+ <>
+ {[1, 2].map((i) => (
+
+ ))}
+ >
+ ) : ownedSets.length === 0 ? (
+
+ No polling groups created yet
+
+ ) : (
+ ownedSets.map((set) => (
+
setViewingSet(set)}
+ >
+
+
+ {getProviderIcon(set.providerId)}
+
+
+ {set.name}
+
+ {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+
+
+
+
{
+ e.stopPropagation()
+ setSelectedSetId(set.id)
+ setShowInviteModal(true)
+ }}
+ >
+ Add Members
+
+
+ ))
+ )}
+
+ )}
+
+ )}
From 3f7cab4c8260420009c93d4230a9983f135445b8 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 13:20:16 -0800
Subject: [PATCH 17/21] consolidate webhook code better
---
.../app/api/webhooks/trigger/[path]/route.ts | 101 +++---------------
apps/sim/lib/webhooks/processor.ts | 66 ++++++++++++
2 files changed, 83 insertions(+), 84 deletions(-)
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 16660d9699..f3aba1e7b4 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -4,10 +4,13 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
+ formatProviderErrorResponse,
+ handlePreDeploymentVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
queueWebhookExecution,
+ shouldSkipWebhookEvent,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
@@ -22,19 +25,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const requestId = generateRequestId()
const { path } = await params
- // Handle Microsoft Graph subscription validation
- const url = new URL(request.url)
- const validationToken = url.searchParams.get('validationToken')
-
- if (validationToken) {
- logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
- return new NextResponse(validationToken, {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- })
- }
-
- // Handle other GET-based verifications if needed
+ // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.)
const challengeResponse = await handleProviderChallenges({}, request, requestId, path)
if (challengeResponse) {
return challengeResponse
@@ -50,26 +41,10 @@ export async function POST(
const requestId = generateRequestId()
const { path } = await params
- // Log ALL incoming webhook requests for debugging
- logger.info(`[${requestId}] Incoming webhook request`, {
- path,
- method: request.method,
- headers: Object.fromEntries(request.headers.entries()),
- })
-
- // Handle Microsoft Graph subscription validation (some environments send POST with validationToken)
- try {
- const url = new URL(request.url)
- const validationToken = url.searchParams.get('validationToken')
- if (validationToken) {
- logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`)
- return new NextResponse(validationToken, {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- })
- }
- } catch {
- // ignore URL parsing errors; proceed to normal handling
+ // Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
+ const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
+ if (earlyChallenge) {
+ return earlyChallenge
}
const parseResult = await parseWebhookBody(request, requestId)
@@ -99,23 +74,6 @@ export async function POST(
const responses: NextResponse[] = []
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
- // Log HubSpot webhook details for debugging
- if (foundWebhook.provider === 'hubspot') {
- const events = Array.isArray(body) ? body : [body]
- const firstEvent = events[0]
-
- logger.info(`[${requestId}] HubSpot webhook received`, {
- path,
- subscriptionType: firstEvent?.subscriptionType,
- objectId: firstEvent?.objectId,
- portalId: firstEvent?.portalId,
- webhookId: foundWebhook.id,
- workflowId: foundWorkflow.id,
- triggerId: foundWebhook.providerConfig?.triggerId,
- eventCount: events.length,
- })
- }
-
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
@@ -162,32 +120,19 @@ export async function POST(
continue
}
- if (foundWebhook.provider === 'microsoft-teams') {
- return NextResponse.json(
- {
- type: 'message',
- text: 'An unexpected error occurred during preprocessing',
- },
- { status: 500 }
- )
- }
-
- return NextResponse.json(
- { error: 'An unexpected error occurred during preprocessing' },
- { status: 500 }
+ return formatProviderErrorResponse(
+ foundWebhook,
+ 'An unexpected error occurred during preprocessing',
+ 500
)
}
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
- // For Grain, if block doesn't exist in deployment, treat as verification request
- // Grain validates webhook URLs during creation, and the block may not be deployed yet
- if (foundWebhook.provider === 'grain') {
- logger.info(
- `[${requestId}] Grain webhook verification - block not in deployment, returning 200 OK`
- )
- return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
+ const preDeploymentResponse = handlePreDeploymentVerification(foundWebhook, requestId)
+ if (preDeploymentResponse) {
+ return preDeploymentResponse
}
logger.info(
@@ -200,20 +145,8 @@ export async function POST(
}
}
- if (foundWebhook.provider === 'stripe') {
- const providerConfig = (foundWebhook.providerConfig as Record) || {}
- const eventTypes = providerConfig.eventTypes
-
- if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
- const eventType = body?.type
-
- if (eventType && !eventTypes.includes(eventType)) {
- logger.info(
- `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${foundWebhook.id}, skipping`
- )
- continue
- }
- }
+ if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
+ continue
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index cc4922080c..ab85f30f89 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -153,6 +153,17 @@ export async function handleProviderChallenges(
}
const url = new URL(request.url)
+
+ // Microsoft Graph subscription validation (can come as GET or POST)
+ const validationToken = url.searchParams.get('validationToken')
+ if (validationToken) {
+ logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
+ return new NextResponse(validationToken, {
+ status: 200,
+ headers: { 'Content-Type': 'text/plain' },
+ })
+ }
+
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
@@ -193,6 +204,61 @@ export function handleProviderReachabilityTest(
return null
}
+/**
+ * Format error response based on provider requirements.
+ * Some providers (like Microsoft Teams) require specific response formats.
+ */
+export function formatProviderErrorResponse(
+ webhook: any,
+ error: string,
+ status: number
+): NextResponse {
+ if (webhook.provider === 'microsoft-teams') {
+ return NextResponse.json({ type: 'message', text: error }, { status })
+ }
+ return NextResponse.json({ error }, { status })
+}
+
+/**
+ * Check if a webhook event should be skipped based on provider-specific filtering.
+ * Returns true if the event should be skipped, false if it should be processed.
+ */
+export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean {
+ const providerConfig = (webhook.providerConfig as Record) || {}
+
+ if (webhook.provider === 'stripe') {
+ const eventTypes = providerConfig.eventTypes
+ if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
+ const eventType = body?.type
+ if (eventType && !eventTypes.includes(eventType)) {
+ logger.info(
+ `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping`
+ )
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+/** Providers that validate webhook URLs during creation, before workflow deployment */
+const PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION = new Set(['grain'])
+
+/** Returns 200 OK for providers that validate URLs before the workflow is deployed */
+export function handlePreDeploymentVerification(
+ webhook: any,
+ requestId: string
+): NextResponse | null {
+ if (PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION.has(webhook.provider)) {
+ logger.info(
+ `[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation`
+ )
+ return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
+ }
+ return null
+}
+
export async function findWebhookAndWorkflow(
options: WebhookProcessorOptions
): Promise<{ webhook: any; workflow: any } | null> {
From 9d979189c3dff1bfa1a372a82782b5469f7801c1 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 13:21:46 -0800
Subject: [PATCH 18/21] fix tests
---
apps/sim/app/api/webhooks/trigger/[path]/route.test.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
index 26670c9ac6..fff521ca8f 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
@@ -249,6 +249,12 @@ vi.mock('@/lib/webhooks/processor', () => ({
}
),
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
+ formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
+ const { NextResponse } = require('next/server')
+ return NextResponse.json({ error }, { status })
+ }),
+ shouldSkipWebhookEvent: vi.fn().mockReturnValue(false),
+ handlePreDeploymentVerification: vi.fn().mockReturnValue(null),
queueWebhookExecution: vi.fn().mockImplementation(async () => {
// Call processWebhookMock so tests can verify it was called
processWebhookMock()
From 0373899b0368127e1df58d592ea5c85c61f807d6 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 13:39:40 -0800
Subject: [PATCH 19/21] fix credential collab logic issue
---
.../credential-selector/credential-selector.tsx | 11 +++++++----
.../components/tool-credential-selector.tsx | 5 +++--
apps/sim/executor/constants.ts | 4 ++++
apps/sim/hooks/queries/oauth-credentials.ts | 6 ++++--
4 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index 73b5467061..4a4f112e23 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -17,7 +17,7 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
-import { CREDENTIAL_SET } from '@/executor/constants'
+import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
@@ -116,12 +116,15 @@ export function CredentialSelector({
[credentialSets, selectedCredentialSetId]
)
+ const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
+
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
+ if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
- if (isForeign) return 'Saved by collaborator'
+ if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
- }, [selectedCredentialSet, selectedCredential, isForeign])
+ }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
@@ -363,7 +366,7 @@ export function CredentialSelector({
}
disabled={effectiveDisabled}
editable={true}
- filterOptions={true}
+ filterOptions={!isForeign && !isForeignCredentialSet}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
index 761935d7d3..d202efb775 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
@@ -10,6 +10,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
+import { CREDENTIAL } from '@/executor/constants'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -95,7 +96,7 @@ export function ToolCredentialSelector({
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
- if (isForeign) return 'Saved by collaborator'
+ if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
@@ -210,7 +211,7 @@ export function ToolCredentialSelector({
placeholder={label}
disabled={disabled}
editable={true}
- filterOptions={true}
+ filterOptions={!isForeign}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index 5432279cbc..f483bbfc78 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -185,6 +185,10 @@ export const CREDENTIAL_SET = {
PREFIX: 'credentialSet:',
} as const
+export const CREDENTIAL = {
+ FOREIGN_LABEL: 'Saved by collaborator',
+} as const
+
export function isCredentialSetValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
}
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
index 650858b048..414fae2d9c 100644
--- a/apps/sim/hooks/queries/oauth-credentials.ts
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
-import { CREDENTIAL_SET } from '@/executor/constants'
+import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
@@ -93,11 +93,13 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
)
const hasForeignMeta = foreignCredentials.length > 0
+ const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading
const displayName =
credentialSetData?.name ??
selectedCredential?.name ??
- (hasForeignMeta ? 'Saved by collaborator' : null)
+ (hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ??
+ (isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null)
return {
displayName,
From 888f789016f88aa507fbb18598e569819898f7ef Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 17:39:31 -0800
Subject: [PATCH 20/21] fix ui
---
.../[id]/invite/[invitationId]/route.ts | 146 +++
.../[id]/invitations/[invitationId]/route.ts | 99 ++
.../app/credential-account/[token]/page.tsx | 224 ++--
.../credential-sets/credential-sets.tsx | 993 ++++++++++++------
.../member-invitation-card.tsx | 15 +-
.../components/team-members/team-members.tsx | 148 ++-
.../components/invite-modal/invite-modal.tsx | 279 ++++-
apps/sim/hooks/queries/credential-sets.ts | 103 ++
apps/sim/hooks/queries/organization.ts | 26 +
bun.lock | 1 -
10 files changed, 1509 insertions(+), 525 deletions(-)
create mode 100644 apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
new file mode 100644
index 0000000000..839538ff7c
--- /dev/null
+++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
@@ -0,0 +1,146 @@
+import { db } from '@sim/db'
+import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
+import { getSession } from '@/lib/auth'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { sendEmail } from '@/lib/messaging/email/mailer'
+
+const logger = createLogger('CredentialSetInviteResend')
+
+async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
+ const [set] = await db
+ .select({
+ id: credentialSet.id,
+ organizationId: credentialSet.organizationId,
+ name: credentialSet.name,
+ providerId: credentialSet.providerId,
+ })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (!set) return null
+
+ const [membership] = await db
+ .select({ role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
+ .limit(1)
+
+ if (!membership) return null
+
+ return { set, role: membership.role }
+}
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string; invitationId: string }> }
+) {
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id, invitationId } = await params
+
+ try {
+ const result = await getCredentialSetWithAccess(id, session.user.id)
+
+ if (!result) {
+ return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
+ }
+
+ if (result.role !== 'admin' && result.role !== 'owner') {
+ return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
+ }
+
+ const [invitation] = await db
+ .select()
+ .from(credentialSetInvitation)
+ .where(
+ and(
+ eq(credentialSetInvitation.id, invitationId),
+ eq(credentialSetInvitation.credentialSetId, id)
+ )
+ )
+ .limit(1)
+
+ if (!invitation) {
+ return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
+ }
+
+ if (invitation.status !== 'pending') {
+ return NextResponse.json({ error: 'Only pending invitations can be resent' }, { status: 400 })
+ }
+
+ // Update expiration
+ const newExpiresAt = new Date()
+ newExpiresAt.setDate(newExpiresAt.getDate() + 7)
+
+ await db
+ .update(credentialSetInvitation)
+ .set({ expiresAt: newExpiresAt })
+ .where(eq(credentialSetInvitation.id, invitationId))
+
+ const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}`
+
+ // Send email if email address exists
+ if (invitation.email) {
+ try {
+ const [inviter] = await db
+ .select({ name: user.name })
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .limit(1)
+
+ const [org] = await db
+ .select({ name: organization.name })
+ .from(organization)
+ .where(eq(organization.id, result.set.organizationId))
+ .limit(1)
+
+ const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email'
+ const emailHtml = await renderPollingGroupInvitationEmail({
+ inviterName: inviter?.name || 'A team member',
+ organizationName: org?.name || 'your organization',
+ pollingGroupName: result.set.name,
+ provider,
+ inviteLink: inviteUrl,
+ })
+
+ const emailResult = await sendEmail({
+ to: invitation.email,
+ subject: getEmailSubject('polling-group-invitation'),
+ html: emailHtml,
+ emailType: 'transactional',
+ })
+
+ if (!emailResult.success) {
+ logger.warn('Failed to resend invitation email', {
+ email: invitation.email,
+ error: emailResult.message,
+ })
+ return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
+ }
+ } catch (emailError) {
+ logger.error('Error sending invitation email', emailError)
+ return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
+ }
+ }
+
+ logger.info('Resent credential set invitation', {
+ credentialSetId: id,
+ invitationId,
+ userId: session.user.id,
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error('Error resending invitation', error)
+ return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
index bf9332caa0..143a924cc3 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
@@ -15,8 +15,11 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
+import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation')
@@ -69,6 +72,102 @@ export async function GET(
}
}
+// Resend invitation
+export async function POST(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string; invitationId: string }> }
+) {
+ const { id: organizationId, invitationId } = await params
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ // Verify user is admin/owner
+ const memberEntry = await db
+ .select()
+ .from(member)
+ .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
+ .limit(1)
+
+ if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) {
+ return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
+ }
+
+ const orgInvitation = await db
+ .select()
+ .from(invitation)
+ .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
+ .then((rows) => rows[0])
+
+ if (!orgInvitation) {
+ return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
+ }
+
+ if (orgInvitation.status !== 'pending') {
+ return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
+ }
+
+ const org = await db
+ .select({ name: organization.name })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .then((rows) => rows[0])
+
+ const inviter = await db
+ .select({ name: user.name })
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .limit(1)
+
+ // Update expiration date
+ const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
+ await db
+ .update(invitation)
+ .set({ expiresAt: newExpiresAt })
+ .where(eq(invitation.id, invitationId))
+
+ // Send email
+ const emailHtml = await renderInvitationEmail(
+ inviter[0]?.name || 'Someone',
+ org?.name || 'organization',
+ `${getBaseUrl()}/invite/${invitationId}`
+ )
+
+ const emailResult = await sendEmail({
+ to: orgInvitation.email,
+ subject: getEmailSubject('invitation'),
+ html: emailHtml,
+ emailType: 'transactional',
+ })
+
+ if (!emailResult.success) {
+ logger.error('Failed to resend invitation email', {
+ email: orgInvitation.email,
+ error: emailResult.message,
+ })
+ return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 })
+ }
+
+ logger.info('Organization invitation resent', {
+ organizationId,
+ invitationId,
+ resentBy: session.user.id,
+ email: orgInvitation.email,
+ })
+
+ return NextResponse.json({
+ success: true,
+ message: 'Invitation resent successfully',
+ })
+ } catch (error) {
+ logger.error('Error resending organization invitation:', error)
+ return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
+ }
+}
+
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
index 2abed9dc0c..4bddd16265 100644
--- a/apps/sim/app/credential-account/[token]/page.tsx
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -1,12 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
-import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
+import { Mail } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { Button } from '@/components/emcn'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { client, useSession } from '@/lib/auth/auth-client'
import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
+import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
interface InvitationInfo {
credentialSetName: string
@@ -129,133 +129,141 @@ export default function CredentialAccountInvitePage() {
}
}, [session?.user?.id, token, router, invitation?.providerId])
+ const providerName = invitation?.providerId
+ ? getProviderDisplayName(invitation.providerId)
+ : 'email'
+
+ const ProviderIcon =
+ invitation?.providerId === 'outlook'
+ ? OutlookIcon
+ : invitation?.providerId === 'google-email'
+ ? GmailIcon
+ : Mail
+
+ const providerWithIcon = (
+
+
+ {providerName}
+
+ )
+
+ const getCallbackUrl = () => `/credential-account/${token}`
+
if (loading || sessionLoading) {
return (
-
-
-
+
+
+
)
}
if (error) {
return (
-
-
-
-
- Unable to load invitation
-
-
{error}
-
-
+
+ router.push('/'),
+ },
+ ]}
+ />
+
)
}
- const ProviderIcon =
- invitation?.providerId === 'outlook'
- ? OutlookIcon
- : invitation?.providerId === 'google-email'
- ? GmailIcon
- : Mail
- const providerName = invitation?.providerId
- ? getProviderDisplayName(invitation.providerId)
- : 'email'
-
if (acceptedState === 'already-connected') {
return (
-
-
-
-
You're all set!
-
- You've joined {invitation?.credentialSetName}. Your {providerName} account is already
- connected.
-
-
Redirecting to workspace...
-
-
-
+
+
+
)
}
if (acceptedState === 'connecting') {
return (
-
-
-
-
- Connecting to {providerName}...
-
-
- You've joined {invitation?.credentialSetName}. You'll be redirected to connect your{' '}
- {providerName} account.
-
-
-
-
+
+
+
)
}
- return (
-
-
-
-
-
- Join Email Polling Group
-
-
- You've been invited to join{' '}
-
- {invitation?.credentialSetName}
- {' '}
- by {invitation?.organizationName}
-
- {invitation?.providerId && (
-
- You'll be asked to connect your {providerName} account after accepting.
-
- )}
-
+ // Not logged in
+ if (!session?.user) {
+ const callbackUrl = encodeURIComponent(getCallbackUrl())
-
- {session?.user ? (
- <>
-
- Logged in as{' '}
- {session.user.email}
-
-
- {accepting ? (
- <>
-
- Joining...
- >
- ) : (
- <>
-
- Accept & Connect {providerName}
- >
- )}
-
- >
- ) : (
- <>
-
- Sign in or create an account to accept this invitation
-
-
- Continue
-
- >
- )}
-
+ return (
+
+ router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
+ },
+ {
+ label: 'Create an account',
+ onClick: () =>
+ router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
+ variant: 'outline' as const,
+ },
+ {
+ label: 'Return to Home',
+ onClick: () => router.push('/'),
+ variant: 'ghost' as const,
+ },
+ ]}
+ />
+
+ )
+ }
-
- By joining, you agree to share your {providerName} credentials with this polling group for
- use in automated email workflows.
-
-
-
+ // Logged in - show invitation
+ return (
+
+
+ You've been invited to join {invitation?.credentialSetName} by{' '}
+ {invitation?.organizationName}.
+ {invitation?.providerId && (
+ <> You'll be asked to connect your {providerWithIcon} account after accepting.>
+ )}
+ >
+ }
+ icon='mail'
+ actions={[
+ {
+ label: `Accept & Connect ${providerName}`,
+ onClick: handleAccept,
+ disabled: accepting,
+ loading: accepting,
+ },
+ {
+ label: 'Return to Home',
+ onClick: () => router.push('/'),
+ variant: 'ghost' as const,
+ },
+ ]}
+ />
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 4ded0cb733..74a71edb60 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -1,8 +1,8 @@
'use client'
-import { useCallback, useState } from 'react'
+import { type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ArrowLeft, Loader2, Plus, User } from 'lucide-react'
+import { Paperclip, Plus, Search, X } from 'lucide-react'
import {
Avatar,
AvatarFallback,
@@ -16,26 +16,32 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
- Textarea,
} from '@/components/emcn'
import { GmailIcon, OutlookIcon } from '@/components/icons'
-import { Skeleton } from '@/components/ui'
+import { Input as BaseInput, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { cn } from '@/lib/core/utils/cn'
import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
+import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { getUserRole } from '@/lib/workspaces/organization'
+import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components'
+import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import {
type CredentialSet,
useAcceptCredentialSetInvitation,
+ useCancelCredentialSetInvitation,
useCreateCredentialSet,
useCreateCredentialSetInvitation,
useCredentialSetInvitations,
+ useCredentialSetInvitationsDetail,
useCredentialSetMembers,
useCredentialSetMemberships,
useCredentialSets,
+ useDeleteCredentialSet,
useLeaveCredentialSet,
useRemoveCredentialSetMember,
+ useResendCredentialSetInvitation,
} from '@/hooks/queries/credential-sets'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
@@ -98,24 +104,38 @@ export function CredentialSets() {
const createCredentialSet = useCreateCredentialSet()
const createInvitation = useCreateCredentialSetInvitation()
+ const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
- const [showInviteModal, setShowInviteModal] = useState(false)
- const [selectedSetId, setSelectedSetId] = useState(null)
const [viewingSet, setViewingSet] = useState(null)
const [newSetName, setNewSetName] = useState('')
const [newSetDescription, setNewSetDescription] = useState('')
const [newSetProvider, setNewSetProvider] = useState('google-email')
const [createError, setCreateError] = useState(null)
- const [inviteEmails, setInviteEmails] = useState('')
+ const [emails, setEmails] = useState([])
+ const [invalidEmails, setInvalidEmails] = useState([])
+ const [duplicateEmails, setDuplicateEmails] = useState([])
+ const [inputValue, setInputValue] = useState('')
const [isDragging, setIsDragging] = useState(false)
+ const fileInputRef = useRef(null)
const [leavingMembership, setLeavingMembership] = useState<{
credentialSetId: string
name: string
} | null>(null)
const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id)
+ const { data: pendingInvitations = [], isPending: pendingInvitationsLoading } =
+ useCredentialSetInvitationsDetail(viewingSet?.id)
const removeMember = useRemoveCredentialSetMember()
const leaveCredentialSet = useLeaveCredentialSet()
+ const deleteCredentialSet = useDeleteCredentialSet()
+ const cancelInvitation = useCancelCredentialSetInvitation()
+ const resendInvitation = useResendCredentialSetInvitation()
+
+ const [deletingSet, setDeletingSet] = useState<{ id: string; name: string } | null>(null)
+ const [deletingSetIds, setDeletingSetIds] = useState>(new Set())
+ const [cancellingInvitations, setCancellingInvitations] = useState>(new Set())
+ const [resendingInvitations, setResendingInvitations] = useState>(new Set())
+ const [resendCooldowns, setResendCooldowns] = useState>({})
const extractEmailsFromText = useCallback((text: string): string[] => {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
@@ -123,26 +143,140 @@ export function CredentialSets() {
return [...new Set(matches.map((e) => e.toLowerCase()))]
}, [])
+ const addEmail = useCallback(
+ (email: string) => {
+ if (!email.trim()) return false
+
+ const normalized = email.trim().toLowerCase()
+ const validation = quickValidateEmail(normalized)
+ const isValid = validation.isValid
+
+ if (
+ emails.includes(normalized) ||
+ invalidEmails.includes(normalized) ||
+ duplicateEmails.includes(normalized)
+ ) {
+ return false
+ }
+
+ const isPendingInvitation = pendingInvitations.some(
+ (inv) => inv.email?.toLowerCase() === normalized
+ )
+ if (isPendingInvitation) {
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ const isActiveMember = members.some(
+ (m) => m.userEmail?.toLowerCase() === normalized && m.status === 'active'
+ )
+ if (isActiveMember) {
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ if (!isValid) {
+ setInvalidEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ setEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return true
+ },
+ [emails, invalidEmails, duplicateEmails, pendingInvitations, members]
+ )
+
+ const removeEmail = useCallback((index: number) => {
+ setEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const removeInvalidEmail = useCallback((index: number) => {
+ setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const removeDuplicateEmail = useCallback((index: number) => {
+ setDuplicateEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const handleEmailKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
+ return
+ }
+
+ if ([',', ' '].includes(e.key) && inputValue.trim()) {
+ e.preventDefault()
+ addEmail(inputValue)
+ }
+
+ if (e.key === 'Backspace' && !inputValue) {
+ if (duplicateEmails.length > 0) {
+ removeDuplicateEmail(duplicateEmails.length - 1)
+ } else if (invalidEmails.length > 0) {
+ removeInvalidEmail(invalidEmails.length - 1)
+ } else if (emails.length > 0) {
+ removeEmail(emails.length - 1)
+ }
+ }
+ },
+ [
+ inputValue,
+ addEmail,
+ duplicateEmails,
+ invalidEmails,
+ emails,
+ removeDuplicateEmail,
+ removeInvalidEmail,
+ removeEmail,
+ ]
+ )
+
+ const handleEmailPaste = useCallback(
+ (e: React.ClipboardEvent) => {
+ e.preventDefault()
+ const pastedText = e.clipboardData.getData('text')
+ const pastedEmails = extractEmailsFromText(pastedText)
+
+ pastedEmails.forEach((email) => {
+ addEmail(email)
+ })
+ },
+ [addEmail, extractEmailsFromText]
+ )
+
const handleFileDrop = useCallback(
async (file: File) => {
try {
const text = await file.text()
- const emails = extractEmailsFromText(text)
- if (emails.length > 0) {
- setInviteEmails((prev) => {
- const existing = prev
- .split(/[,\n]/)
- .map((e) => e.trim())
- .filter((e) => e.length > 0)
- const combined = [...new Set([...existing, ...emails])]
- return combined.join('\n')
- })
- }
+ const extractedEmails = extractEmailsFromText(text)
+ extractedEmails.forEach((email) => {
+ addEmail(email)
+ })
} catch (error) {
logger.error('Error reading dropped file', error)
}
},
- [extractEmailsFromText]
+ [extractEmailsFromText, addEmail]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -180,6 +314,21 @@ export function CredentialSets() {
[handleFileDrop]
)
+ const handleFileInputChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ await handleFileDrop(file)
+ }
+
+ // Reset input so the same file can be selected again
+ e.target.value = ''
+ },
+ [handleFileDrop]
+ )
+
const handleRemoveMember = useCallback(
async (memberId: string) => {
if (!viewingSet) return
@@ -235,10 +384,9 @@ export function CredentialSets() {
setNewSetDescription('')
setNewSetProvider('google-email')
- // Open invite modal for the newly created group
- if (result?.credentialSet?.id) {
- setSelectedSetId(result.credentialSet.id)
- setShowInviteModal(true)
+ // Open detail view for the newly created group
+ if (result?.credentialSet) {
+ setViewingSet(result.credentialSet)
}
} catch (error) {
logger.error('Failed to create polling group', error)
@@ -250,30 +398,31 @@ export function CredentialSets() {
}
}, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet])
- const handleCreateInvite = useCallback(async () => {
- if (!selectedSetId) return
+ const handleInviteMembers = useCallback(async () => {
+ if (!viewingSet?.id) return
- const emails = inviteEmails
- .split(/[,\n]/)
- .map((e) => e.trim())
- .filter((e) => e.length > 0 && e.includes('@'))
+ // Add any pending input value first
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
if (emails.length === 0) return
try {
for (const email of emails) {
await createInvitation.mutateAsync({
- credentialSetId: selectedSetId,
+ credentialSetId: viewingSet.id,
email,
})
}
- setInviteEmails('')
- setShowInviteModal(false)
- setSelectedSetId(null)
+ setEmails([])
+ setInvalidEmails([])
+ setDuplicateEmails([])
+ setInputValue('')
} catch (error) {
logger.error('Failed to create invitations', error)
}
- }, [selectedSetId, inviteEmails, createInvitation])
+ }, [viewingSet?.id, emails, inputValue, addEmail, createInvitation])
const handleCloseCreateModal = useCallback(() => {
setShowCreateModal(false)
@@ -283,204 +432,439 @@ export function CredentialSets() {
setCreateError(null)
}, [])
- const handleCloseInviteModal = useCallback(() => {
- setShowInviteModal(false)
- setInviteEmails('')
- setSelectedSetId(null)
+ const handleBackToList = useCallback(() => {
+ setViewingSet(null)
+ setEmails([])
+ setInvalidEmails([])
+ setDuplicateEmails([])
+ setInputValue('')
+ }, [])
+
+ const handleCancelInvitation = useCallback(
+ async (invitationId: string) => {
+ if (!viewingSet?.id) return
+
+ setCancellingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await cancelInvitation.mutateAsync({
+ credentialSetId: viewingSet.id,
+ invitationId,
+ })
+ } catch (error) {
+ logger.error('Failed to cancel invitation', error)
+ } finally {
+ setCancellingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ },
+ [viewingSet?.id, cancelInvitation]
+ )
+
+ const handleResendInvitation = useCallback(
+ async (invitationId: string, email: string) => {
+ if (!viewingSet?.id) return
+
+ const secondsLeft = resendCooldowns[invitationId]
+ if (secondsLeft && secondsLeft > 0) return
+
+ setResendingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await resendInvitation.mutateAsync({
+ credentialSetId: viewingSet.id,
+ invitationId,
+ email,
+ })
+
+ // Start 60s cooldown
+ setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
+ const interval = setInterval(() => {
+ setResendCooldowns((prev) => {
+ const current = prev[invitationId]
+ if (current === undefined) return prev
+ if (current <= 1) {
+ const next = { ...prev }
+ delete next[invitationId]
+ clearInterval(interval)
+ return next
+ }
+ return { ...prev, [invitationId]: current - 1 }
+ })
+ }, 1000)
+ } catch (error) {
+ logger.error('Failed to resend invitation', error)
+ } finally {
+ setResendingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ },
+ [viewingSet?.id, resendInvitation, resendCooldowns]
+ )
+
+ const handleDeleteClick = useCallback((set: CredentialSet) => {
+ setDeletingSet({ id: set.id, name: set.name })
}, [])
+ const confirmDelete = useCallback(async () => {
+ if (!deletingSet || !activeOrganization?.id) return
+ setDeletingSetIds((prev) => new Set(prev).add(deletingSet.id))
+ try {
+ await deleteCredentialSet.mutateAsync({
+ credentialSetId: deletingSet.id,
+ organizationId: activeOrganization.id,
+ })
+ setDeletingSet(null)
+ } catch (error) {
+ logger.error('Failed to delete polling group', error)
+ } finally {
+ setDeletingSetIds((prev) => {
+ const next = new Set(prev)
+ next.delete(deletingSet.id)
+ return next
+ })
+ }
+ }, [deletingSet, activeOrganization?.id, deleteCredentialSet])
+
const getProviderIcon = (providerId: string | null) => {
if (providerId === 'outlook') return
return
}
- if (membershipsLoading || invitationsLoading) {
- return
- }
+ // All hooks must be called before any early returns
+ const activeMemberships = useMemo(
+ () => memberships.filter((m) => m.status === 'active'),
+ [memberships]
+ )
+
+ const filteredInvitations = useMemo(() => {
+ if (!searchTerm.trim()) return invitations
+ const searchLower = searchTerm.toLowerCase()
+ return invitations.filter(
+ (inv) =>
+ inv.credentialSetName.toLowerCase().includes(searchLower) ||
+ inv.organizationName.toLowerCase().includes(searchLower)
+ )
+ }, [invitations, searchTerm])
+
+ const filteredMemberships = useMemo(() => {
+ if (!searchTerm.trim()) return activeMemberships
+ const searchLower = searchTerm.toLowerCase()
+ return activeMemberships.filter(
+ (m) =>
+ m.credentialSetName.toLowerCase().includes(searchLower) ||
+ m.organizationName.toLowerCase().includes(searchLower)
+ )
+ }, [activeMemberships, searchTerm])
+
+ const filteredOwnedSets = useMemo(() => {
+ if (!searchTerm.trim()) return ownedSets
+ const searchLower = searchTerm.toLowerCase()
+ return ownedSets.filter((set) => set.name.toLowerCase().includes(searchLower))
+ }, [ownedSets, searchTerm])
- const activeMemberships = memberships.filter((m) => m.status === 'active')
const hasNoContent =
invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0
+ const hasNoResults =
+ searchTerm.trim() &&
+ filteredInvitations.length === 0 &&
+ filteredMemberships.length === 0 &&
+ filteredOwnedSets.length === 0 &&
+ !hasNoContent
+
+ // Early returns AFTER all hooks
+ if (membershipsLoading || invitationsLoading) {
+ return
+ }
// Detail view for a polling group
if (viewingSet) {
const activeMembers = members.filter((m) => m.status === 'active')
- const pendingMembers = members.filter((m) => m.status === 'pending')
+ const totalCount = activeMembers.length + pendingInvitations.length
return (
<>
-
-
-
setViewingSet(null)} className='h-9 w-9 p-0'>
-
-
-
-
- {getProviderIcon(viewingSet.providerId)}
+
+
+ {/* Group Info */}
+
+
+
+ Group Name
+
+
+ {viewingSet.name}
+
+
+
+
+
+ Provider
+
+
+ {getProviderIcon(viewingSet.providerId)}
+
+ {getProviderDisplayName(viewingSet.providerId as PollingProvider)}
+
+
-
{viewingSet.name}
-
-
{
- setSelectedSetId(viewingSet.id)
- setShowInviteModal(true)
- }}
- >
-
- Add Members
-
-
-
- {membersLoading ? (
-
-
- {[1, 2].map((i) => (
-
-
-
-
-
-
-
+ {/* Invite Section - Email Tags Input */}
+
+
+
+ {isDragging && (
+
+
+ Drop file here
+
+
+ )}
+ {invalidEmails.map((email, index) => (
+
removeInvalidEmail(index)}
+ disabled={createInvitation.isPending}
+ isInvalid={true}
+ />
+ ))}
+ {duplicateEmails.map((email, index) => (
+
+ {email}
+ duplicate
+ {!createInvitation.isPending && (
+ removeDuplicateEmail(index)}
+ className='flex-shrink-0 text-amber-600 transition-colors hover:text-amber-700 focus:outline-none dark:text-amber-400 dark:hover:text-amber-300'
+ aria-label={`Remove ${email}`}
+ >
+
+
+ )}
-
+ ))}
+ {emails.map((email, index) => (
+ removeEmail(index)}
+ disabled={createInvitation.isPending}
+ />
+ ))}
+
- ))}
-
- ) : members.length === 0 ? (
-
- No members yet
+
+
+ {createInvitation.isPending ? 'Sending...' : 'Invite'}
+
- ) : (
+
+ {/* Members List - styled like team members */}
- {activeMembers.length > 0 && (
-
-
- Active Members ({activeMembers.length})
-
- {activeMembers.map((member) => (
-
+
Members
+
+ {membersLoading || pendingInvitationsLoading ? (
+
+ {[1, 2].map((i) => (
+
-
-
-
-
-
-
-
-
-
- {member.userName || 'Unknown'}
-
- {member.credentials.length === 0 && (
-
- Disconnected
-
- )}
-
-
- {member.userEmail}
-
+
+
+
+
-
handleRemoveMember(member.id)}
- disabled={removeMember.isPending}
- >
- Remove
-
))}
- )}
+ ) : totalCount === 0 ? (
+
+ No members yet. Send invitations above.
+
+ ) : (
+
+ {/* Active Members */}
+ {activeMembers.map((member) => {
+ const name = member.userName || 'Unknown'
+ const avatarInitial = name.charAt(0).toUpperCase()
- {pendingMembers.length > 0 && (
-
-
- Pending ({pendingMembers.length})
-
- {pendingMembers.map((member) => (
-
-
-
-
-
-
-
-
-
-
- {member.userName || 'Unknown'}
-
-
- {member.userEmail}
-
+ return (
+
+
+
+ {member.userImage && (
+
+ )}
+
+ {avatarInitial}
+
+
+
+
+
+
+ {name}
+
+ {member.credentials.length === 0 && (
+
+ Disconnected
+
+ )}
+
+
+ {member.userEmail}
+
+
+
+
+
+ handleRemoveMember(member.id)}
+ disabled={removeMember.isPending}
+ className='h-8'
+ >
+ Remove
+
-
Pending
-
- ))}
+ )
+ })}
+
+ {/* Pending Invitations */}
+ {pendingInvitations.map((invitation) => {
+ const email = invitation.email || 'Unknown'
+ const emailPrefix = email.split('@')[0]
+ const avatarInitial = emailPrefix.charAt(0).toUpperCase()
+
+ return (
+
+
+
+
+ {avatarInitial}
+
+
+
+
+
+
+ {emailPrefix}
+
+
+ Pending
+
+
+
+ {email}
+
+
+
+
+
+ handleResendInvitation(invitation.id, email)}
+ disabled={
+ resendingInvitations.has(invitation.id) ||
+ (resendCooldowns[invitation.id] ?? 0) > 0
+ }
+ className='h-8'
+ >
+ {resendingInvitations.has(invitation.id)
+ ? 'Sending...'
+ : resendCooldowns[invitation.id]
+ ? `Resend (${resendCooldowns[invitation.id]}s)`
+ : 'Resend'}
+
+ handleCancelInvitation(invitation.id)}
+ disabled={cancellingInvitations.has(invitation.id)}
+ className='h-8'
+ >
+ {cancellingInvitations.has(invitation.id)
+ ? 'Cancelling...'
+ : 'Cancel'}
+
+
+
+ )
+ })}
)}
- )}
+
-
-
-
- Add Members
-
-
-
Email Addresses
-
-
- Invitees will receive an email with a link to connect their account.
-
-
-
-
-
- Cancel
-
-
- {createInvitation.isPending ? (
-
- ) : (
- 'Send Invites'
- )}
-
-
-
-
+ {/* Footer Actions */}
+
+
+ Back
+
+
+
>
)
}
@@ -488,20 +872,45 @@ export function CredentialSets() {
return (
<>
+
+
+
+ setSearchTerm(e.target.value)}
+ className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
+ />
+
+ {canManageCredentialSets && (
+
setShowCreateModal(true)}>
+
+ Create
+
+ )}
+
+
{hasNoContent && !canManageCredentialSets ? (
You're not a member of any polling groups yet. When someone invites you, it will
appear here.
+ ) : hasNoResults ? (
+
+ No results found matching "{searchTerm}"
+
) : (
- {invitations.length > 0 && (
+ {filteredInvitations.length > 0 && (
Pending Invitations
- {invitations.map((invitation) => (
+ {filteredInvitations.map((invitation) => (
handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
- {acceptInvitation.isPending ? (
-
- ) : (
- 'Accept'
- )}
+ {acceptInvitation.isPending ? 'Accepting...' : 'Accept'}
))}
)}
- {activeMemberships.length > 0 && (
+ {filteredMemberships.length > 0 && (
My Memberships
- {activeMemberships.map((membership) => (
+ {filteredMemberships.map((membership) => (
)}
- {canManageCredentialSets && (
-
-
+ {canManageCredentialSets &&
+ (filteredOwnedSets.length > 0 ||
+ ownedSetsLoading ||
+ (!searchTerm.trim() && ownedSets.length === 0)) && (
+
Manage
-
setShowCreateModal(true)}>
-
- Create
-
-
- {ownedSetsLoading ? (
- <>
- {[1, 2].map((i) => (
-
-
-
-
-
-
+ {ownedSetsLoading ? (
+ <>
+ {[1, 2].map((i) => (
+
-
-
- ))}
- >
- ) : ownedSets.length === 0 ? (
-
- No polling groups created yet
-
- ) : (
- ownedSets.map((set) => (
-
setViewingSet(set)}
- >
-
-
- {getProviderIcon(set.providerId)}
+ ))}
+ >
+ ) : !searchTerm.trim() && ownedSets.length === 0 ? (
+
+ No polling groups created yet
+
+ ) : (
+ filteredOwnedSets.map((set) => (
+
+
+
+ {getProviderIcon(set.providerId)}
+
+
+ {set.name}
+
+ {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+
+
-
-
{set.name}
-
- {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
-
+
+ setViewingSet(set)}>
+ Details
+
+ handleDeleteClick(set)}
+ disabled={deletingSetIds.has(set.id)}
+ >
+ {deletingSetIds.has(set.id) ? 'Deleting...' : 'Delete'}
+
-
{
- e.stopPropagation()
- setSelectedSetId(set.id)
- setShowInviteModal(true)
- }}
- >
- Add Members
-
-
- ))
- )}
-
- )}
+ ))
+ )}
+
+ )}
)}
+ {/* Create Polling Group Modal */}
Create Polling Group
@@ -706,88 +1106,61 @@ export function CredentialSets() {
onClick={handleCreateCredentialSet}
disabled={!newSetName.trim() || createCredentialSet.isPending}
>
- {createCredentialSet.isPending ? (
-
- ) : (
- 'Create'
- )}
+ {createCredentialSet.isPending ? 'Creating...' : 'Create'}
-
+ {/* Leave Confirmation Modal */}
+ setLeavingMembership(null)}>
- Add Members
+ Leave Polling Group
-
-
Email Addresses
-
-
- Invitees will receive an email with a link to connect their account.
-
-
+
+ Are you sure you want to leave{' '}
+
+ {leavingMembership?.name}
+
+ ? Your email account will no longer be polled in workflows using this group.
+
-
+ setLeavingMembership(null)}>
Cancel
- {createInvitation.isPending ? (
-
- ) : (
- 'Send Invites'
- )}
+ {leaveCredentialSet.isPending ? 'Leaving...' : 'Leave'}
- setLeavingMembership(null)}>
+ {/* Delete Confirmation Modal */}
+ setDeletingSet(null)}>
- Leave Polling Group
+ Delete Polling Group
- Are you sure you want to leave{' '}
-
- {leavingMembership?.name}
-
- ? Your email account will no longer be polled in workflows using this group.
+ Are you sure you want to delete{' '}
+ {deletingSet?.name} ?{' '}
+ This action cannot be undone.
- setLeavingMembership(null)}>
+ setDeletingSet(null)}>
Cancel
- {leaveCredentialSet.isPending ? 'Leaving...' : 'Leave'}
+ {deleteCredentialSet.isPending ? 'Deleting...' : 'Delete'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx
index 6d3edbd64f..e176e813e5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx
@@ -1,7 +1,7 @@
'use client'
import React, { useMemo, useState } from 'react'
-import { CheckCircle, ChevronDown } from 'lucide-react'
+import { ChevronDown } from 'lucide-react'
import {
Button,
Checkbox,
@@ -302,14 +302,11 @@ export function MemberInvitationCard({
{/* Success message */}
{inviteSuccess && (
-
-
-
- Invitation sent successfully
- {selectedCount > 0 &&
- ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
-
-
+
+ Invitation sent successfully
+ {selectedCount > 0 &&
+ ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
index d5d1016b59..317258bc1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
@@ -5,7 +5,11 @@ import { createLogger } from '@sim/logger'
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
-import { useCancelInvitation, useOrganizationMembers } from '@/hooks/queries/organization'
+import {
+ useCancelInvitation,
+ useOrganizationMembers,
+ useResendInvitation,
+} from '@/hooks/queries/organization'
const logger = createLogger('TeamMembers')
@@ -46,12 +50,16 @@ export function TeamMembers({
onRemoveMember,
}: TeamMembersProps) {
const [cancellingInvitations, setCancellingInvitations] = useState
>(new Set())
+ const [resendingInvitations, setResendingInvitations] = useState>(new Set())
+ const [resentInvitations, setResentInvitations] = useState>(new Set())
+ const [resendCooldowns, setResendCooldowns] = useState>({})
const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers(
organization?.id || ''
)
const cancelInvitationMutation = useCancelInvitation()
+ const resendInvitationMutation = useResendInvitation()
const memberUsageData: Record = {}
if (memberUsageResponse?.data) {
@@ -140,6 +148,54 @@ export function TeamMembers({
}
}
+ const handleResendInvitation = async (invitationId: string) => {
+ if (!organization?.id) return
+
+ const secondsLeft = resendCooldowns[invitationId]
+ if (secondsLeft && secondsLeft > 0) return
+
+ setResendingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await resendInvitationMutation.mutateAsync({
+ invitationId,
+ orgId: organization.id,
+ })
+
+ setResentInvitations((prev) => new Set([...prev, invitationId]))
+ setTimeout(() => {
+ setResentInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }, 4000)
+
+ // Start 60s cooldown
+ setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
+ const interval = setInterval(() => {
+ setResendCooldowns((prev) => {
+ const current = prev[invitationId]
+ if (current === undefined) return prev
+ if (current <= 1) {
+ const next = { ...prev }
+ delete next[invitationId]
+ clearInterval(interval)
+ return next
+ }
+ return { ...prev, [invitationId]: current - 1 }
+ })
+ }, 1000)
+ } catch (error) {
+ logger.error('Failed to resend invitation', { error })
+ } finally {
+ setResendingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ }
+
return (
{/* Header */}
@@ -148,13 +204,13 @@ export function TeamMembers({
{/* Members list */}
-
+
{teamItems.map((item) => (
{/* Left section: Avatar + Name/Role + Action buttons */}
{/* Avatar */}
-
+
{item.avatarUrl && }
)}
{item.type === 'invitation' && (
-
+
Pending
)}
-
{item.email}
+
{item.email}
- {/* Action buttons */}
- {isAdminOrOwner && (
- <>
- {/* Admin/Owner can remove other members */}
- {item.type === 'member' &&
- item.role !== 'owner' &&
- item.email !== currentUserEmail && (
-
onRemoveMember(item.member)}
- className='h-8'
- >
- Remove
-
- )}
-
- {/* Admin can cancel invitations */}
- {item.type === 'invitation' && (
+ {/* Action buttons for members */}
+ {isAdminOrOwner &&
+ item.type === 'member' &&
+ item.role !== 'owner' &&
+ item.email !== currentUserEmail && (
+
onRemoveMember(item.member)}
+ className='h-8'
+ >
+ Remove
+
+ )}
+
+
+ {/* Right section */}
+ {isAdminOrOwner && (
+
+ {item.type === 'member' ? (
+ <>
+
Usage
+
+ {isLoadingUsage ? (
+
+ ) : (
+ item.usage
+ )}
+
+ >
+ ) : (
+
+ handleResendInvitation(item.invitation.id)}
+ disabled={
+ resendingInvitations.has(item.invitation.id) ||
+ (resendCooldowns[item.invitation.id] ?? 0) > 0
+ }
+ className='h-8'
+ >
+ {resendingInvitations.has(item.invitation.id)
+ ? 'Sending...'
+ : resendCooldowns[item.invitation.id]
+ ? `Resend (${resendCooldowns[item.invitation.id]}s)`
+ : 'Resend'}
+
handleCancelInvitation(item.invitation.id)}
@@ -213,22 +297,8 @@ export function TeamMembers({
>
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
- )}
- >
- )}
-
-
- {/* Right section: Usage column (right-aligned) */}
- {isAdminOrOwner && (
-
-
Usage
-
- {isLoadingUsage && item.type === 'member' ? (
-
- ) : (
- item.usage
- )}
-
+
+ )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index 62450150f7..520f2d6c8c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -2,6 +2,7 @@
import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
+import { Paperclip, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -40,9 +41,12 @@ interface PendingInvitation {
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef(null)
+ const fileInputRef = useRef(null)
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState([])
const [invalidEmails, setInvalidEmails] = useState([])
+ const [duplicateEmails, setDuplicateEmails] = useState([])
+ const [isDragging, setIsDragging] = useState(false)
const [userPermissions, setUserPermissions] = useState([])
const [pendingInvitations, setPendingInvitations] = useState([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
@@ -134,13 +138,20 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid
- if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
+ if (
+ emails.includes(normalized) ||
+ invalidEmails.includes(normalized) ||
+ duplicateEmails.includes(normalized)
+ ) {
return false
}
const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized)
if (hasPendingInvitation) {
- setErrorMessage(`${normalized} already has a pending invitation`)
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
@@ -149,7 +160,10 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
(user) => user.email === normalized
)
if (isExistingMember) {
- setErrorMessage(`${normalized} is already a member of this workspace`)
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
@@ -161,13 +175,19 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}
if (!isValid) {
- setInvalidEmails((prev) => [...prev, normalized])
+ setInvalidEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
setErrorMessage(null)
- setEmails((prev) => [...prev, normalized])
+ setEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setUserPermissions((prev) => [
...prev,
@@ -180,7 +200,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInputValue('')
return true
},
- [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email]
+ [
+ emails,
+ invalidEmails,
+ duplicateEmails,
+ pendingInvitations,
+ workspacePermissions?.users,
+ session?.user?.email,
+ ]
)
const removeEmail = useCallback(
@@ -196,6 +223,80 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
+ const removeDuplicateEmail = useCallback((index: number) => {
+ setDuplicateEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const extractEmailsFromText = useCallback((text: string): string[] => {
+ const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
+ const matches = text.match(emailRegex) || []
+ return [...new Set(matches.map((e) => e.toLowerCase()))]
+ }, [])
+
+ const handleFileDrop = useCallback(
+ async (file: File) => {
+ try {
+ const text = await file.text()
+ const extractedEmails = extractEmailsFromText(text)
+ extractedEmails.forEach((email) => {
+ addEmail(email)
+ })
+ } catch (error) {
+ logger.error('Error reading dropped file', error)
+ }
+ },
+ [extractEmailsFromText, addEmail]
+ )
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ setIsDragging(true)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+ }, [])
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+
+ const files = Array.from(e.dataTransfer.files)
+ const validFiles = files.filter(
+ (f) =>
+ f.type === 'text/csv' ||
+ f.type === 'text/plain' ||
+ f.name.endsWith('.csv') ||
+ f.name.endsWith('.txt')
+ )
+
+ for (const file of validFiles) {
+ await handleFileDrop(file)
+ }
+ },
+ [handleFileDrop]
+ )
+
+ const handleFileInputChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ await handleFileDrop(file)
+ }
+
+ e.target.value = ''
+ },
+ [handleFileDrop]
+ )
+
const handlePermissionChange = useCallback(
(identifier: string, permissionType: PermissionType) => {
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
@@ -204,11 +305,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setExistingUserPermissionChanges((prev) => {
const newChanges = { ...prev }
- // If the new permission matches the original, remove the change entry
if (existingUser.permissionType === permissionType) {
delete newChanges[identifier]
} else {
- // Otherwise, track the change
newChanges[identifier] = { permissionType }
}
@@ -297,7 +396,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setErrorMessage(null)
try {
- // Verify the user exists in workspace permissions
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
@@ -322,7 +420,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to remove member')
}
- // Update the workspace permissions to remove the user
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
@@ -333,7 +430,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
})
}
- // Clear any pending changes for this user
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
@@ -384,7 +480,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to cancel invitation')
}
- // Remove the invitation from the pending invitations list
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
@@ -452,7 +547,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
delete next[invitationId]
return next
})
- // Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -474,40 +568,52 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
- if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
+ return
+ }
+
+ if ([',', ' '].includes(e.key) && inputValue.trim()) {
e.preventDefault()
addEmail(inputValue)
}
if (e.key === 'Backspace' && !inputValue) {
- if (invalidEmails.length > 0) {
+ if (duplicateEmails.length > 0) {
+ removeDuplicateEmail(duplicateEmails.length - 1)
+ } else if (invalidEmails.length > 0) {
removeInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
removeEmail(emails.length - 1)
}
}
},
- [inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail]
+ [
+ inputValue,
+ addEmail,
+ duplicateEmails,
+ invalidEmails,
+ emails,
+ removeDuplicateEmail,
+ removeInvalidEmail,
+ removeEmail,
+ ]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
- const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
+ const pastedEmails = extractEmailsFromText(pastedText)
- let addedCount = 0
pastedEmails.forEach((email) => {
- if (addEmail(email)) {
- addedCount++
- }
+ addEmail(email)
})
-
- if (addedCount === 0 && pastedEmails.length === 1) {
- setInputValue(inputValue + pastedEmails[0])
- }
},
- [addEmail, inputValue]
+ [addEmail, extractEmailsFromText]
)
const handleSubmit = useCallback(
@@ -518,7 +624,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
addEmail(inputValue)
}
- // Clear messages at start of submission
setErrorMessage(null)
setSuccessMessage(null)
@@ -644,10 +749,11 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)
const resetState = useCallback(() => {
- // Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setInvalidEmails([])
+ setDuplicateEmails([])
+ setIsDragging(false)
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
@@ -718,7 +824,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
tabIndex={-1}
readOnly
/>
-
+
+
+ {isDragging && (
+
+
+ Drop file here
+
+
+ )}
{invalidEmails.map((email, index) => (
))}
+ {duplicateEmails.map((email, index) => (
+
+ {email}
+ duplicate
+ {!isSubmitting && userPerms.canAdmin && (
+ removeDuplicateEmail(index)}
+ className='flex-shrink-0 text-amber-600 transition-colors hover:text-amber-700 focus:outline-none dark:text-amber-400 dark:hover:text-amber-300'
+ aria-label={`Remove ${email}`}
+ >
+
+
+ )}
+
+ ))}
{emails.map((email, index) => (
))}
-
setInputValue(e.target.value)}
- onKeyDown={handleKeyDown}
- onPaste={handlePaste}
- onBlur={() => inputValue.trim() && addEmail(inputValue)}
- placeholder={
- !userPerms.canAdmin
- ? 'Only administrators can invite new members'
- : emails.length > 0 || invalidEmails.length > 0
- ? 'Add another email'
- : 'Enter emails'
- }
- className={cn(
- 'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
- emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
+
+
setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ onBlur={() => inputValue.trim() && addEmail(inputValue)}
+ placeholder={
+ !userPerms.canAdmin
+ ? 'Only administrators can invite new members'
+ : emails.length > 0 ||
+ invalidEmails.length > 0 ||
+ duplicateEmails.length > 0
+ ? 'Add another email'
+ : 'Enter emails'
+ }
+ className={cn(
+ 'h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
+ emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0
+ ? 'pl-[4px]'
+ : 'pl-[4px]'
+ )}
+ autoFocus={userPerms.canAdmin}
+ disabled={isSubmitting || !userPerms.canAdmin}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck={false}
+ data-lpignore='true'
+ data-form-type='other'
+ aria-autocomplete='none'
+ />
+ {userPerms.canAdmin && (
+
fileInputRef.current?.click()}
+ className='ml-[4px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
+ disabled={isSubmitting}
+ >
+
+
)}
- autoFocus={userPerms.canAdmin}
- disabled={isSubmitting || !userPerms.canAdmin}
- autoComplete='off'
- autoCorrect='off'
- autoCapitalize='off'
- spellCheck={false}
- data-lpignore='true'
- data-form-type='other'
- aria-autocomplete='none'
- />
+
{errorMessage && (
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
index ec0da3f954..33da082f75 100644
--- a/apps/sim/hooks/queries/credential-sets.ts
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -265,3 +265,106 @@ export function useLeaveCredentialSet() {
},
})
}
+
+export interface DeleteCredentialSetParams {
+ credentialSetId: string
+ organizationId: string
+}
+
+export function useDeleteCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => {
+ const response = await fetch(`/api/credential-sets/${credentialSetId}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to delete credential set')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: credentialSetKeys.list(variables.organizationId),
+ })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ },
+ })
+}
+
+export interface CredentialSetInvitationDetail {
+ id: string
+ credentialSetId: string
+ email: string | null
+ token: string
+ status: string
+ expiresAt: string
+ createdAt: string
+ invitedBy: string
+}
+
+interface InvitationsDetailResponse {
+ invitations?: CredentialSetInvitationDetail[]
+}
+
+export function useCredentialSetInvitationsDetail(credentialSetId?: string) {
+ return useQuery({
+ queryKey: [...credentialSetKeys.detail(credentialSetId), 'invitations'],
+ queryFn: async () => {
+ const data = await fetchJson(
+ `/api/credential-sets/${credentialSetId}/invite`
+ )
+ return (data.invitations ?? []).filter((inv) => inv.status === 'pending')
+ },
+ enabled: Boolean(credentialSetId),
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useCancelCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; invitationId: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/invite?invitationId=${data.invitationId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to cancel invitation')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
+ })
+ },
+ })
+}
+
+export function useResendCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; invitationId: string; email: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/invite/${data.invitationId}`,
+ { method: 'POST' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to resend invitation')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
+ })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index e3ed5b4c6d..7f4fbe7ee4 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -363,6 +363,32 @@ export function useCancelInvitation() {
})
}
+/**
+ * Resend invitation mutation
+ */
+interface ResendInvitationParams {
+ invitationId: string
+ orgId: string
+}
+
+export function useResendInvitation() {
+ return useMutation({
+ mutationFn: async ({ invitationId, orgId }: ResendInvitationParams) => {
+ const response = await fetch(`/api/organizations/${orgId}/invitations/${invitationId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || 'Failed to resend invitation')
+ }
+
+ return response.json()
+ },
+ })
+}
+
/**
* Update seats mutation (handles both add and reduce)
*/
diff --git a/bun.lock b/bun.lock
index d62ed31d02..fc86d072df 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
From 0178dbcb787c18cf871ff79b93bd70eb7849e084 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 7 Jan 2026 17:40:53 -0800
Subject: [PATCH 21/21] fix lint
---
.../components/credential-sets/credential-sets.tsx | 2 +-
.../workspace-header/components/invite-modal/invite-modal.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
index 74a71edb60..5fadc9e7bd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -629,7 +629,7 @@ export function CredentialSets() {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
- 'relative flex min-h-9 flex-1 flex-wrap items-center gap-x-[8px] gap-y-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] focus-within:outline-none transition-colors',
+ 'relative flex min-h-9 flex-1 flex-wrap items-center gap-x-[8px] gap-y-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] transition-colors focus-within:outline-none',
isDragging && 'border-[var(--border)] border-dashed bg-[var(--surface-5)]'
)}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index 520f2d6c8c..a05eedaed4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -836,7 +836,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
- 'relative scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] focus-within:outline-none transition-colors',
+ 'scrollbar-hide relative flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] transition-colors focus-within:outline-none',
isDragging && 'border-[var(--border)] border-dashed bg-[var(--surface-5)]'
)}
>