import { db } from '@sim/db' import { account, credential, credentialMember, document, knowledgeBase, mcpServers, workflow, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SelectorValidator') /** * Result of selector ID validation */ export interface SelectorValidationResult { valid: string[] invalid: string[] warning?: string } /** * Validates that selector IDs exist in the database * Returns valid IDs, invalid IDs, and optional warning for unknown selector types */ export async function validateSelectorIds( selectorType: string, ids: string | string[], context: { userId: string; workspaceId?: string } ): Promise { const idsArray = (Array.isArray(ids) ? ids : [ids]).filter((id) => id && id.trim() !== '') if (idsArray.length === 0) { return { valid: [], invalid: [] } } let existingIds: string[] = [] try { switch (selectorType) { case 'oauth-input': { if (context.workspaceId) { // In workspace workflows, oauth-input values are workspace credential IDs. // Accept both current credential IDs and legacy account IDs. Workspace // admins (incl. derived org admins) can reference any shared credential; // other members need active credential membership. const isWorkspaceAdmin = (await checkWorkspaceAccess(context.workspaceId, context.userId)) .canAdmin const matchWhere = and( eq(credential.workspaceId, context.workspaceId), inArray(credential.type, ['oauth', 'service_account']), or(inArray(credential.id, idsArray), inArray(credential.accountId, idsArray)) ) const results = await db .select({ credentialId: credential.id, accountId: credential.accountId }) .from(credential) .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, context.userId), eq(credentialMember.status, 'active') ) ) .where(and(matchWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id))) existingIds = Array.from( new Set( results.flatMap((result) => { const matches: string[] = [] if (idsArray.includes(result.credentialId)) { matches.push(result.credentialId) } if (result.accountId && idsArray.includes(result.accountId)) { matches.push(result.accountId) } return matches }) ) ) const existingSet = new Set(existingIds) const invalidIds = idsArray.filter((id) => !existingSet.has(id)) if (invalidIds.length > 0) { const accessibleSelect = { id: credential.id, displayName: credential.displayName, accountId: credential.accountId, credentialProviderId: credential.providerId, accountProviderId: account.providerId, } const accessibleWhere = and( eq(credential.workspaceId, context.workspaceId), inArray(credential.type, ['oauth', 'service_account']) ) const allAccessibleCredentials = await db .select(accessibleSelect) .from(credential) .leftJoin(account, eq(credential.accountId, account.id)) .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, context.userId), eq(credentialMember.status, 'active') ) ) .where( and(accessibleWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id)) ) const availableCredentials = allAccessibleCredentials .map((cred) => { const providerId = cred.accountProviderId || cred.credentialProviderId || 'unknown-provider' const legacyId = cred.accountId ? `, legacy ${cred.accountId}` : '' return `${cred.displayName} [${cred.id}] (${providerId}${legacyId})` }) .join(', ') const noCredentialsMessage = 'User has no accessible credentials in this workspace.' return { valid: existingIds, invalid: invalidIds, warning: allAccessibleCredentials.length > 0 ? `Accessible workspace credentials: ${availableCredentials}` : noCredentialsMessage, } } break } // Fallback for older callers that still validate against legacy account IDs. const results = await db .select({ id: account.id }) .from(account) .where(and(inArray(account.id, idsArray), eq(account.userId, context.userId))) existingIds = results.map((r) => r.id) // If any IDs are invalid, fetch user's available credentials to include in error message const existingSet = new Set(existingIds) const invalidIds = idsArray.filter((id) => !existingSet.has(id)) if (invalidIds.length > 0) { // Fetch all of the user's credentials to provide helpful feedback const allUserCredentials = await db .select({ id: account.id, providerId: account.providerId }) .from(account) .where(eq(account.userId, context.userId)) const availableCredentials = allUserCredentials .map((c) => `${c.id} (${c.providerId})`) .join(', ') const noCredentialsMessage = 'User has no credentials configured.' return { valid: existingIds, invalid: invalidIds, warning: allUserCredentials.length > 0 ? `Available credentials for this user: ${availableCredentials}` : noCredentialsMessage, } } break } case 'knowledge-base-selector': { const conditions = [inArray(knowledgeBase.id, idsArray), isNull(knowledgeBase.deletedAt)] if (context.workspaceId) { conditions.push(eq(knowledgeBase.workspaceId, context.workspaceId)) } const results = await db .select({ id: knowledgeBase.id }) .from(knowledgeBase) .where(and(...conditions)) existingIds = results.map((r) => r.id) break } case 'workflow-selector': { const results = await db .select({ id: workflow.id }) .from(workflow) .where(and(inArray(workflow.id, idsArray), isNull(workflow.archivedAt))) existingIds = results.map((r) => r.id) break } case 'document-selector': { // Documents in knowledge bases - check if they exist and are not deleted const results = await db .select({ id: document.id }) .from(document) .where( and( inArray(document.id, idsArray), eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt) ) ) existingIds = results.map((r) => r.id) break } case 'mcp-server-selector': { // MCP servers - check if they exist, are enabled, and not deleted in the workspace const conditions = [ inArray(mcpServers.id, idsArray), eq(mcpServers.enabled, true), isNull(mcpServers.deletedAt), ] if (context.workspaceId) { conditions.push(eq(mcpServers.workspaceId, context.workspaceId)) } const results = await db .select({ id: mcpServers.id }) .from(mcpServers) .where(and(...conditions)) existingIds = results.map((r) => r.id) break } // MCP tools are dynamically fetched from external MCP servers at runtime // We can't validate tool IDs locally - only the server connection validates them case 'mcp-tool-selector': { return { valid: idsArray, invalid: [] } } // These selectors use external IDs from third-party systems (Slack, Google, Jira, etc.) // We can't validate them locally - they're validated at runtime when calling the external API case 'file-selector': case 'project-selector': case 'channel-selector': case 'folder-selector': { // External/dynamic selectors - skip validation, IDs are validated at runtime // These IDs come from: Slack channels, Google Drive files, Jira projects, etc. return { valid: idsArray, invalid: [] } } default: { // Unknown selector type - skip validation but warn logger.warn(`Unknown selector type "${selectorType}" - ID validation skipped`, { selectorType, idCount: idsArray.length, }) return { valid: idsArray, invalid: [], warning: `Unknown selector type "${selectorType}" - ID validation skipped`, } } } } catch (error) { // On DB error, skip validation rather than failing the edit logger.error(`Failed to validate selector IDs for type "${selectorType}"`, { error: toError(error).message, selectorType, idCount: idsArray.length, }) return { valid: idsArray, invalid: [], warning: `Failed to validate ${selectorType} IDs - validation skipped`, } } const existingSet = new Set(existingIds) return { valid: idsArray.filter((id) => existingSet.has(id)), invalid: idsArray.filter((id) => !existingSet.has(id)), } } /** * Batch validate multiple selector fields * Returns a map of field name to validation result */ async function validateAllSelectorFields( fields: Array<{ fieldName: string; selectorType: string; value: string | string[] }>, context: { userId: string; workspaceId?: string } ): Promise> { const results = new Map() // Run validations in parallel for better performance const validationPromises = fields.map(async ({ fieldName, selectorType, value }) => { const result = await validateSelectorIds(selectorType, value, context) return { fieldName, result } }) const validationResults = await Promise.all(validationPromises) for (const { fieldName, result } of validationResults) { results.set(fieldName, result) } return results }