From 244cf4ff7e84b6f4eb30b29828c58af798b86c65 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 12:34:28 -0800 Subject: [PATCH] feat(selectors): add dropdown selectors for 14 integrations (#3433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(selectors): add dropdown selectors for 14 integrations * fix(selectors): secure OAuth tokens in JSM and Confluence selector routes Convert JSM selector-servicedesks, selector-requesttypes, and Confluence selector-spaces routes from GET (with access token in URL query params) to POST with authorizeCredentialUse + refreshAccessTokenIfNeeded pattern. Also adds missing ensureCredential guard to microsoft.planner.plans registry entry. * fix(selectors): use sanitized serviceDeskId and encode SharePoint siteId Use serviceDeskIdValidation.sanitized instead of raw serviceDeskId in JSM request types URL. Add encodeURIComponent to SharePoint siteId to prevent URL path injection. * lint * fix(selectors): revert encodeURIComponent on SharePoint siteId SharePoint site IDs use the format "hostname,guid,guid" with commas that must remain unencoded for the Microsoft Graph API. The encodeURIComponent call would convert commas to %2C and break the API call. * fix(selectors): use sanitized cloudId in Confluence and JSM route URLs Use cloudIdValidation.sanitized instead of raw cloudId in URL construction for consistency with the validation pattern, even though the current validator returns the input unchanged. * fix(selectors): add missing context fields to resolution, ensureCredential to sharepoint.lists, and siteId validation - Add baseId, datasetId, serviceDeskId to SelectorResolutionArgs, ExtendedSelectorContext, extractExtendedContext, useSelectorDisplayName, and resolveSelectorForSubBlock so cascading selectors resolve correctly through the resolution path. - Add ensureCredential guard to sharepoint.lists registry entry. - Add regex validation for SharePoint siteId format (hostname,GUID,GUID). * fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes Rename all advanced-mode subBlock IDs that matched their canonicalParamId to use a `manual*` prefix, following the established convention (e.g., manualSiteId, manualCredential). This prevents ambiguity between subBlock IDs and canonical parameter names in the serialization layer. 25 renames across 14 blocks: baseId→manualBaseId, tableId→manualTableId, workspace→manualWorkspace, objectType→manualObjectType, etc. * Revert "fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes" This reverts commit 4e30161c68e640f1c2e4ca79d16b69f12d631ab2. * fix(selectors): rename canonicalParamIds to avoid subBlock ID clashes Prefix all clashing canonicalParamId values with `selected_` so they don't match any subBlock ID. Update each block's `inputs` section and `tools.config.params` function to destructure the new canonical names and remap them to the original tool param names. SubBlock IDs and tool definitions remain unchanged for backwards compatibility. Affected: 25 canonical params across 14 blocks (airtable, asana, attio, calcom, confluence, google_bigquery, google_tasks, jsm, microsoft_planner, notion, pipedrive, sharepoint, trello, zoom). * fix(selectors): rename pre-existing driveId and files canonicalParamIds in SharePoint Apply the same selected_ prefix convention to the pre-existing SharePoint driveId and files canonical params that clashed with their subBlock IDs. * style: format long lines in calcom, pipedrive, and sharepoint blocks Co-Authored-By: Claude Opus 4.6 * fix(selectors): resolve cascading context for selected_ canonical params and normalize Asana response Strip `selected_` prefix from canonical param IDs when mapping to SelectorContext fields so cascading selectors (Airtable base→table, BigQuery dataset→table, JSM serviceDesk→requestType) correctly propagate parent values. Normalize Asana workspaces route to return `{ id, name }` instead of `{ gid, name }` for consistency with all other selector routes. Co-Authored-By: Claude Opus 4.6 * fix(selectors): replace hacky prefix stripping with explicit CANONICAL_TO_CONTEXT mapping Replace CONTEXT_FIELD_SET (Record) with CANONICAL_TO_CONTEXT (Record) that explicitly maps canonical param IDs to their SelectorContext field names. This properly handles the selected_ prefix aliases (e.g. selected_baseId → baseId) without string manipulation, and removes the unsafe Record cast. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): remove unnecessary selected_ prefix from canonicalParamIds The selected_ prefix was added to avoid a perceived clash between canonicalParamId and subBlock id values, but this clash does not actually cause any issues — pre-existing blocks on main (Google Sheets, Webflow, SharePoint) already use matching values successfully. Remove the prefix from all 14 blocks, revert use-selector-setup.ts to the simple CONTEXT_FIELD_SET pattern, and simplify tools.config.params functions that were only remapping the prefix back. Co-Authored-By: Claude Opus 4.6 * fix(selectors): add spaceId selector pair to Confluence V2 block The V2 block was missing the spaceSelector basic-mode selector that the V1 (Legacy) block already had. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): revert V1 block changes, add selectors to Notion V1 for V2 inheritance Confluence V1: reverted to main state (V2 has its own subBlocks). Notion V1: added selector pairs per-operation since V2 inherits subBlocks, inputs, and params from V1. Co-Authored-By: Claude Opus 4.6 * fix(selectors): audit fixes for auth patterns, registry gaps, and display name resolution - Convert Microsoft Planner plans/tasks routes from GET+getSession to POST+authorizeCredentialUse - Add fetchById to microsoft.planner (tasks) and sharepoint.sites registry entries - Add ensureCredential to sharepoint.sites and microsoft.planner registry fetchList - Update microsoft.planner.plans registry to use POST method - Add siteId, collectionId, spreadsheetId, fileId to SelectorDisplayNameArgs and caller - Add fileId to SelectorResolutionArgs and resolution context - Fix Zoom topicUpdate visibility in basic mode (remove mode:'advanced') - Change Zoom meetings selector to fetch upcoming_meetings instead of only scheduled Co-Authored-By: Claude Opus 4.6 * style: lint formatting fixes Co-Authored-By: Claude Opus 4.6 * fix(selectors): consolidate Notion canonical param pairs into array conditions Co-Authored-By: Claude Opus 4.6 * fix(selectors): add missing selectorKey to Confluence V1 page selector Co-Authored-By: Claude Opus 4.6 * fix(selectors): use sanitized IDs in URLs, convert SharePoint routes to POST+authorizeCredentialUse - Use planIdValidation.sanitized in MS Planner tasks fetch URL - Convert sharepoint/lists and sharepoint/sites from GET+getSession to POST+authorizeCredentialUse - Update registry entries to match POST pattern Co-Authored-By: Claude Opus 4.6 * fix(selectors): revert Zoom meetings type to scheduled for broader compatibility Co-Authored-By: Claude Opus 4.6 * fix(selectors): add SharePoint site ID validator, fix cascading selector display name fallbacks - Add validateSharePointSiteId to input-validation.ts - Use validation util in SharePoint lists route instead of inline regex - Add || fallback to selector IDs in workflow-block.tsx so cascading display names resolve in basic mode (baseSelector, planSelector, etc.) Co-Authored-By: Claude Opus 4.6 * fix(selectors): hoist requestId before try block in all selector routes Co-Authored-By: Claude Opus 4.6 * fix(selectors): hoist requestId before try block in Trello boards route Co-Authored-By: Claude Opus 4.6 * fix(selectors): guard selector queries against unresolved variable references Skip fetchById and context population when values are design-time placeholders ( or {{ENV_VAR}}) rather than real IDs. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): replace hardcoded display name fallbacks with canonical-aware resolution Use resolveDependencyValue to resolve context values for useSelectorDisplayName, eliminating manual || getStringValue('*Selector') fallbacks that required updating for each new selector pair. Co-Authored-By: Claude Opus 4.6 * fix(selectors): tighten SharePoint site ID validation to exclude underscores SharePoint composite site IDs use hostname,guid,guid format where only alphanumerics, periods, hyphens, and commas are valid characters. Co-Authored-By: Claude Opus 4.6 * fix(selectors): ensure string IDs in Pipedrive/Cal.com routes, fix Trello closed board filter Pipedrive pipelines and Cal.com event-types/schedules routes now consistently return string IDs via String() conversion. Trello boards route no longer filters out closed boards, preserving them for fetchById lookups. The closed filter is applied only in the registry's fetchList so archived boards don't appear in dropdowns but can still be resolved by ID for display names. Co-Authored-By: Claude Opus 4.6 * fix(selectors): convert Zoom meeting IDs to strings for consistency Zoom API returns numeric meeting IDs. Convert with String() to match the string ID convention used by all other selector routes. Co-Authored-By: Claude Opus 4.6 * fix(selectors): align registry types with route string ID returns Routes already convert numeric IDs to strings via String(), so update the registry types (CalcomEventType, CalcomSchedule, PipedrivePipeline, ZoomMeeting) from id: number to id: string and remove the now-redundant String() coercions in fetchList/fetchById. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../sim/app/api/tools/airtable/bases/route.ts | 79 ++ .../app/api/tools/airtable/tables/route.ts | 95 ++ .../app/api/tools/asana/workspaces/route.ts | 79 ++ apps/sim/app/api/tools/attio/lists/route.ts | 79 ++ apps/sim/app/api/tools/attio/objects/route.ts | 79 ++ .../app/api/tools/calcom/event-types/route.ts | 83 ++ .../app/api/tools/calcom/schedules/route.ts | 80 ++ .../tools/confluence/selector-spaces/route.ts | 96 ++ .../tools/google_bigquery/datasets/route.ts | 100 +++ .../api/tools/google_bigquery/tables/route.ts | 94 ++ .../tools/google_tasks/task-lists/route.ts | 79 ++ .../tools/jsm/selector-requesttypes/route.ts | 103 +++ .../tools/jsm/selector-servicedesks/route.ts | 94 ++ .../tools/microsoft_planner/plans/route.ts | 72 ++ .../tools/microsoft_planner/tasks/route.ts | 94 +- .../app/api/tools/notion/databases/route.ts | 86 ++ apps/sim/app/api/tools/notion/pages/route.ts | 86 ++ .../api/tools/pipedrive/pipelines/route.ts | 79 ++ .../app/api/tools/sharepoint/lists/route.ts | 91 ++ .../app/api/tools/sharepoint/sites/route.ts | 84 +- apps/sim/app/api/tools/trello/boards/route.ts | 87 ++ apps/sim/app/api/tools/zoom/meetings/route.ts | 82 ++ .../sub-block/hooks/use-selector-setup.ts | 5 + .../workflow-block/workflow-block.tsx | 35 +- apps/sim/blocks/blocks.test.ts | 13 +- apps/sim/blocks/blocks/airtable.ts | 34 +- apps/sim/blocks/blocks/asana.ts | 37 + apps/sim/blocks/blocks/attio.ts | 74 ++ apps/sim/blocks/blocks/calcom.ts | 85 +- apps/sim/blocks/blocks/confluence.ts | 37 +- apps/sim/blocks/blocks/google_bigquery.ts | 32 + apps/sim/blocks/blocks/google_tasks.ts | 21 +- .../blocks/blocks/jira_service_management.ts | 65 +- apps/sim/blocks/blocks/microsoft_planner.ts | 28 +- apps/sim/blocks/blocks/notion.ts | 90 +- apps/sim/blocks/blocks/pipedrive.ts | 42 +- apps/sim/blocks/blocks/sharepoint.ts | 29 +- apps/sim/blocks/blocks/trello.ts | 63 +- apps/sim/blocks/blocks/zoom.ts | 30 +- apps/sim/hooks/selectors/registry.ts | 839 +++++++++++++++++- apps/sim/hooks/selectors/resolution.ts | 8 + apps/sim/hooks/selectors/types.ts | 23 + .../sim/hooks/selectors/use-selector-query.ts | 5 +- apps/sim/hooks/use-selector-display-name.ts | 28 + .../sim/lib/core/security/input-validation.ts | 45 + .../workflows/comparison/resolve-values.ts | 12 + 46 files changed, 3345 insertions(+), 236 deletions(-) create mode 100644 apps/sim/app/api/tools/airtable/bases/route.ts create mode 100644 apps/sim/app/api/tools/airtable/tables/route.ts create mode 100644 apps/sim/app/api/tools/asana/workspaces/route.ts create mode 100644 apps/sim/app/api/tools/attio/lists/route.ts create mode 100644 apps/sim/app/api/tools/attio/objects/route.ts create mode 100644 apps/sim/app/api/tools/calcom/event-types/route.ts create mode 100644 apps/sim/app/api/tools/calcom/schedules/route.ts create mode 100644 apps/sim/app/api/tools/confluence/selector-spaces/route.ts create mode 100644 apps/sim/app/api/tools/google_bigquery/datasets/route.ts create mode 100644 apps/sim/app/api/tools/google_bigquery/tables/route.ts create mode 100644 apps/sim/app/api/tools/google_tasks/task-lists/route.ts create mode 100644 apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts create mode 100644 apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts create mode 100644 apps/sim/app/api/tools/microsoft_planner/plans/route.ts create mode 100644 apps/sim/app/api/tools/notion/databases/route.ts create mode 100644 apps/sim/app/api/tools/notion/pages/route.ts create mode 100644 apps/sim/app/api/tools/pipedrive/pipelines/route.ts create mode 100644 apps/sim/app/api/tools/sharepoint/lists/route.ts create mode 100644 apps/sim/app/api/tools/trello/boards/route.ts create mode 100644 apps/sim/app/api/tools/zoom/meetings/route.ts diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts new file mode 100644 index 0000000000..839c1359dd --- /dev/null +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AirtableBasesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.airtable.com/v0/meta/bases', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Airtable bases', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Airtable bases', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const bases = (data.bases || []).map((base: { id: string; name: string }) => ({ + id: base.id, + name: base.name, + })) + + return NextResponse.json({ bases }) + } catch (error) { + logger.error('Error processing Airtable bases request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Airtable bases', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts new file mode 100644 index 0000000000..41cd68dc12 --- /dev/null +++ b/apps/sim/app/api/tools/airtable/tables/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAirtableId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AirtableTablesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, baseId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!baseId) { + logger.error('Missing baseId in request') + return NextResponse.json({ error: 'Base ID is required' }, { status: 400 }) + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + logger.error('Invalid baseId', { error: baseIdValidation.error }) + return NextResponse.json({ error: baseIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Airtable tables', { + status: response.status, + error: errorData, + baseId, + }) + return NextResponse.json( + { error: 'Failed to fetch Airtable tables', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const tables = (data.tables || []).map((table: { id: string; name: string }) => ({ + id: table.id, + name: table.name, + })) + + return NextResponse.json({ tables }) + } catch (error) { + logger.error('Error processing Airtable tables request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Airtable tables', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts new file mode 100644 index 0000000000..2393ade11c --- /dev/null +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AsanaWorkspacesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://app.asana.com/api/1.0/workspaces', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Asana workspaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Asana workspaces', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const workspaces = (data.data || []).map((workspace: { gid: string; name: string }) => ({ + id: workspace.gid, + name: workspace.name, + })) + + return NextResponse.json({ workspaces }) + } catch (error) { + logger.error('Error processing Asana workspaces request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Asana workspaces', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts new file mode 100644 index 0000000000..1575f7eb3a --- /dev/null +++ b/apps/sim/app/api/tools/attio/lists/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AttioListsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.attio.com/v2/lists', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Attio lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Attio lists', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({ + id: list.api_slug, + name: list.name, + })) + + return NextResponse.json({ lists }) + } catch (error) { + logger.error('Error processing Attio lists request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Attio lists', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts new file mode 100644 index 0000000000..ae3ba5152d --- /dev/null +++ b/apps/sim/app/api/tools/attio/objects/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AttioObjectsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.attio.com/v2/objects', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Attio objects', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Attio objects', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({ + id: obj.api_slug, + name: obj.singular_noun, + })) + + return NextResponse.json({ objects }) + } catch (error) { + logger.error('Error processing Attio objects request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Attio objects', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/calcom/event-types/route.ts b/apps/sim/app/api/tools/calcom/event-types/route.ts new file mode 100644 index 0000000000..b8596f614f --- /dev/null +++ b/apps/sim/app/api/tools/calcom/event-types/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('CalcomEventTypesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.cal.com/v2/event-types', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'cal-api-version': '2024-06-14', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Cal.com event types', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Cal.com event types', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const eventTypes = (data.data || []).map( + (eventType: { id: number; title: string; slug: string }) => ({ + id: String(eventType.id), + title: eventType.title, + slug: eventType.slug, + }) + ) + + return NextResponse.json({ eventTypes }) + } catch (error) { + logger.error('Error processing Cal.com event types request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Cal.com event types', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts new file mode 100644 index 0000000000..8f69328cc6 --- /dev/null +++ b/apps/sim/app/api/tools/calcom/schedules/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('CalcomSchedulesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.cal.com/v2/schedules', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'cal-api-version': '2024-06-11', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Cal.com schedules', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Cal.com schedules', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const schedules = (data.data || []).map((schedule: { id: number; name: string }) => ({ + id: String(schedule.id), + name: schedule.name, + })) + + return NextResponse.json({ schedules }) + } catch (error) { + logger.error('Error processing Cal.com schedules request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts new file mode 100644 index 0000000000..7ae61d3e98 --- /dev/null +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getConfluenceCloudId } from '@/tools/confluence/utils' + +const logger = createLogger('ConfluenceSelectorSpacesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getConfluenceCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250` + + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + logger.error('Confluence API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + const errorMessage = + errorData?.message || `Failed to list Confluence spaces (${response.status})` + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const data = await response.json() + const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({ + id: space.id, + name: space.name, + key: space.key, + })) + + return NextResponse.json({ spaces }) + } catch (error) { + logger.error('Error listing Confluence spaces:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts new file mode 100644 index 0000000000..ffc4ef7235 --- /dev/null +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleBigQueryDatasetsAPI') + +export const dynamic = 'force-dynamic' + +/** + * POST /api/tools/google_bigquery/datasets + * + * Fetches the list of BigQuery datasets for a given project using the caller's OAuth credential. + * + * @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body + * @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName` + */ +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, projectId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!projectId) { + logger.error('Missing project ID in request') + return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets?maxResults=200`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch BigQuery datasets', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery datasets', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const datasets = (data.datasets || []).map( + (ds: { + datasetReference: { datasetId: string; projectId: string } + friendlyName?: string + }) => ({ + datasetReference: ds.datasetReference, + friendlyName: ds.friendlyName, + }) + ) + + return NextResponse.json({ datasets }) + } catch (error) { + logger.error('Error processing BigQuery datasets request:', error) + return NextResponse.json( + { error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts new file mode 100644 index 0000000000..f2f7c6c43c --- /dev/null +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleBigQueryTablesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, projectId, datasetId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!projectId) { + logger.error('Missing project ID in request') + return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) + } + + if (!datasetId) { + logger.error('Missing dataset ID in request') + return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables?maxResults=200`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch BigQuery tables', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery tables', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const tables = (data.tables || []).map( + (t: { tableReference: { tableId: string }; friendlyName?: string }) => ({ + tableReference: t.tableReference, + friendlyName: t.friendlyName, + }) + ) + + return NextResponse.json({ tables }) + } catch (error) { + logger.error('Error processing BigQuery tables request:', error) + return NextResponse.json( + { error: 'Failed to retrieve BigQuery tables', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts new file mode 100644 index 0000000000..6448f21650 --- /dev/null +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleTasksTaskListsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Google Tasks task lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Google Tasks task lists', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const taskLists = (data.items || []).map((list: { id: string; title: string }) => ({ + id: list.id, + title: list.title, + })) + + return NextResponse.json({ taskLists }) + } catch (error) { + logger.error('Error processing Google Tasks task lists request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts new file mode 100644 index 0000000000..a9ef02bec8 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +const logger = createLogger('JsmSelectorRequestTypesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain, serviceDeskId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!serviceDeskId) { + return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 }) + } + + const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId') + if (!serviceDeskIdValidation.isValid) { + return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getJiraCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) + const url = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype?limit=100` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { error: `JSM API error: ${response.status} ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + const requestTypes = (data.values || []).map((rt: { id: string; name: string }) => ({ + id: rt.id, + name: rt.name, + })) + + return NextResponse.json({ requestTypes }) + } catch (error) { + logger.error('Error listing JSM request types:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts new file mode 100644 index 0000000000..b4bc93032f --- /dev/null +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +const logger = createLogger('JsmSelectorServiceDesksAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getJiraCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) + const url = `${baseUrl}/servicedesk?limit=100` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { error: `JSM API error: ${response.status} ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + const serviceDesks = (data.values || []).map((sd: { id: string; projectName: string }) => ({ + id: sd.id, + name: sd.projectName, + })) + + return NextResponse.json({ serviceDesks }) + } catch (error) { + logger.error('Error listing JSM service desks:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts new file mode 100644 index 0000000000..e43650d3d7 --- /dev/null +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('MicrosoftPlannerPlansAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://graph.microsoft.com/v1.0/me/planner/plans', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) + return NextResponse.json( + { error: 'Failed to fetch plans from Microsoft Graph' }, + { status: response.status } + ) + } + + const data = await response.json() + const plans = data.value || [] + + const filteredPlans = plans.map((plan: { id: string; title: string }) => ({ + id: plan.id, + title: plan.title, + })) + + return NextResponse.json({ plans: filteredPlans }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error) + return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index eecfdb48c7..db0ccd88ae 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -1,38 +1,29 @@ -import { randomUUID } from 'crypto' -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { PlannerTask } from '@/tools/microsoft_planner/types' const logger = createLogger('MicrosoftPlannerTasksAPI') -export async function GET(request: NextRequest) { - const requestId = randomUUID().slice(0, 8) +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() try { - const session = await getSession() + const body = await request.json() + const { credential, workflowId, planId } = body - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const planId = searchParams.get('planId') - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } if (!planId) { - logger.error(`[${requestId}] Missing planId parameter`) + logger.error(`[${requestId}] Missing planId in request`) return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 }) } @@ -42,52 +33,35 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: planIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credential, + authz.credentialOwnerUserId, requestId ) - if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) } - const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const response = await fetch( + `https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) if (!response.ok) { const errorText = await response.text() diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts new file mode 100644 index 0000000000..1dee214a2d --- /dev/null +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { extractTitleFromItem } from '@/tools/notion/utils' + +const logger = createLogger('NotionDatabasesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'database', property: 'object' }, + page_size: 100, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion databases', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion databases', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const databases = (data.results || []).map((db: Record) => ({ + id: db.id as string, + name: extractTitleFromItem(db), + })) + + return NextResponse.json({ databases }) + } catch (error) { + logger.error('Error processing Notion databases request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Notion databases', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts new file mode 100644 index 0000000000..0a0bd4f470 --- /dev/null +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { extractTitleFromItem } from '@/tools/notion/utils' + +const logger = createLogger('NotionPagesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'page', property: 'object' }, + page_size: 100, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion pages', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion pages', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const pages = (data.results || []).map((page: Record) => ({ + id: page.id as string, + name: extractTitleFromItem(page), + })) + + return NextResponse.json({ pages }) + } catch (error) { + logger.error('Error processing Notion pages request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Notion pages', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts new file mode 100644 index 0000000000..ba188e6c38 --- /dev/null +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('PipedrivePipelinesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.pipedrive.com/v1/pipelines', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Pipedrive pipelines', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Pipedrive pipelines', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const pipelines = (data.data || []).map((pipeline: { id: number; name: string }) => ({ + id: String(pipeline.id), + name: pipeline.name, + })) + + return NextResponse.json({ pipelines }) + } catch (error) { + logger.error('Error processing Pipedrive pipelines request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Pipedrive pipelines', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts new file mode 100644 index 0000000000..fbbbaab681 --- /dev/null +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateSharePointSiteId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SharePointListsAPI') + +interface SharePointList { + id: string + displayName: string + description?: string + webUrl?: string + list?: { + hidden?: boolean + } +} + +export async function POST(request: Request) { + const requestId = generateRequestId() + + try { + const body = await request.json() + const { credential, workflowId, siteId } = body + + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const siteIdValidation = validateSharePointSiteId(siteId) + if (!siteIdValidation.isValid) { + logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) + } + + const url = `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=100` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch lists from SharePoint' }, + { status: response.status } + ) + } + + const data = await response.json() + const lists = (data.value || []) + .filter((list: SharePointList) => list.list?.hidden !== true) + .map((list: SharePointList) => ({ + id: list.id, + displayName: list.displayName, + })) + + logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`) + return NextResponse.json({ lists }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching lists from SharePoint`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index de161b9730..2119fe975c 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -1,79 +1,45 @@ -import { randomUUID } from 'crypto' -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSitesAPI') -/** - * Get SharePoint sites from Microsoft Graph API - */ -export async function GET(request: NextRequest) { - const requestId = randomUUID().slice(0, 8) +export async function POST(request: Request) { + const requestId = generateRequestId() try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + const body = await request.json() + const { credential, workflowId, query } = body + + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') || '' - - if (!credentialId) { - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credential, + authz.credentialOwnerUserId, requestId ) if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) } const searchQuery = query || '*' diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts new file mode 100644 index 0000000000..fb4ca52738 --- /dev/null +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('TrelloBoardsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const apiKey = process.env.TRELLO_API_KEY + if (!apiKey) { + logger.error('Trello API key not configured') + return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) + } + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.trello.com/1/members/me/boards?key=${apiKey}&token=${accessToken}&fields=id,name,closed`, + { + headers: { + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Trello boards', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Trello boards', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({ + id: board.id, + name: board.name, + closed: board.closed, + })) + + return NextResponse.json({ boards }) + } catch (error) { + logger.error('Error processing Trello boards request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Trello boards', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts new file mode 100644 index 0000000000..01360af761 --- /dev/null +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -0,0 +1,82 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('ZoomMeetingsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + 'https://api.zoom.us/v2/users/me/meetings?page_size=300&type=scheduled', + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Zoom meetings', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Zoom meetings', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const meetings = (data.meetings || []).map((meeting: { id: number; topic: string }) => ({ + id: String(meeting.id), + name: meeting.topic, + })) + + return NextResponse.json({ meetings }) + } catch (error) { + logger.error('Error processing Zoom meetings request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Zoom meetings', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index ff6d5e43b6..e9acaca7dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import type { SubBlockConfig } from '@/blocks/types' +import { isEnvVarReference, isReference } from '@/executor/constants' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useDependsOnGate } from './use-depends-on-gate' @@ -45,6 +46,7 @@ export function useSelectorSetup( if (value === null || value === undefined) continue const strValue = String(value) if (!strValue) continue + if (isReference(strValue) || isEnvVarReference(strValue)) continue const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey @@ -78,4 +80,7 @@ const CONTEXT_FIELD_SET: Record = { collectionId: true, spreadsheetId: true, fileId: true, + baseId: true, + datasetId: true, + serviceDeskId: true, } 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 ae10a76a02..de5694b7b9 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 @@ -549,10 +549,30 @@ const SubBlockRow = memo(function SubBlockRow({ return typeof option === 'string' ? option : option.label }, [subBlock, rawValue]) - const domainValue = getStringValue('domain') - const teamIdValue = getStringValue('teamId') - const projectIdValue = getStringValue('projectId') - const planIdValue = getStringValue('planId') + const resolveContextValue = useCallback( + (key: string): string | undefined => { + const resolved = resolveDependencyValue( + key, + rawValues, + canonicalIndex || buildCanonicalIndex([]), + canonicalModeOverrides + ) + return typeof resolved === 'string' && resolved.length > 0 ? resolved : undefined + }, + [rawValues, canonicalIndex, canonicalModeOverrides] + ) + + const domainValue = resolveContextValue('domain') + const teamIdValue = resolveContextValue('teamId') + const projectIdValue = resolveContextValue('projectId') + const planIdValue = resolveContextValue('planId') + const baseIdValue = resolveContextValue('baseId') + const datasetIdValue = resolveContextValue('datasetId') + const serviceDeskIdValue = resolveContextValue('serviceDeskId') + const siteIdValue = resolveContextValue('siteId') + const collectionIdValue = resolveContextValue('collectionId') + const spreadsheetIdValue = resolveContextValue('spreadsheetId') + const fileIdValue = resolveContextValue('fileId') const { displayName: selectorDisplayName } = useSelectorDisplayName({ subBlock, @@ -564,6 +584,13 @@ const SubBlockRow = memo(function SubBlockRow({ teamId: teamIdValue, projectId: projectIdValue, planId: planIdValue, + baseId: baseIdValue, + datasetId: datasetIdValue, + serviceDeskId: serviceDeskIdValue, + siteId: siteIdValue, + collectionId: collectionIdValue, + spreadsheetId: spreadsheetIdValue, + fileId: fileIdValue, }) const { knowledgeBase: kbForDisplayName } = useKnowledgeBase( diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index dfc8054091..9f668aab81 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -667,15 +667,18 @@ describe.concurrent('Blocks Module', () => { const errors: string[] = [] for (const block of blocks) { - const allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id)) + // Exclude trigger-mode subBlocks — they operate in a separate rendering context + // and their IDs don't participate in canonical param resolution + const nonTriggerSubBlocks = block.subBlocks.filter((sb) => sb.mode !== 'trigger') + const allSubBlockIds = new Set(nonTriggerSubBlocks.map((sb) => sb.id)) const canonicalParamIds = new Set( - block.subBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) + nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) ) for (const canonicalId of canonicalParamIds) { if (allSubBlockIds.has(canonicalId!)) { // Check if the matching subBlock also has a canonicalParamId pointing to itself - const matchingSubBlock = block.subBlocks.find( + const matchingSubBlock = nonTriggerSubBlocks.find( (sb) => sb.id === canonicalId && !sb.canonicalParamId ) if (matchingSubBlock) { @@ -857,6 +860,10 @@ describe.concurrent('Blocks Module', () => { if (typeof subBlock.condition === 'function') { continue } + // Skip trigger-mode subBlocks — they operate in a separate rendering context + if (subBlock.mode === 'trigger') { + continue + } const conditionKey = serializeCondition(subBlock.condition) if (!canonicalByCondition.has(subBlock.canonicalParamId)) { canonicalByCondition.set(subBlock.canonicalParamId, new Set()) diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts index 63fc14b33d..f2b38efb30 100644 --- a/apps/sim/blocks/blocks/airtable.ts +++ b/apps/sim/blocks/blocks/airtable.ts @@ -57,21 +57,51 @@ export const AirtableBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + id: 'baseSelector', + title: 'Base', + type: 'project-selector', + canonicalParamId: 'baseId', + serviceId: 'airtable', + selectorKey: 'airtable.bases', + selectorAllowSearch: false, + placeholder: 'Select Airtable base', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: 'listBases', not: true }, + required: { field: 'operation', value: 'listBases', not: true }, + }, { id: 'baseId', title: 'Base ID', type: 'short-input', + canonicalParamId: 'baseId', placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)', - dependsOn: ['credential'], + mode: 'advanced', condition: { field: 'operation', value: 'listBases', not: true }, required: { field: 'operation', value: 'listBases', not: true }, }, + { + id: 'tableSelector', + title: 'Table', + type: 'file-selector', + canonicalParamId: 'tableId', + serviceId: 'airtable', + selectorKey: 'airtable.tables', + selectorAllowSearch: false, + placeholder: 'Select Airtable table', + dependsOn: ['credential', 'baseSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['listBases', 'listTables'], not: true }, + required: { field: 'operation', value: ['listBases', 'listTables'], not: true }, + }, { id: 'tableId', title: 'Table ID', type: 'short-input', + canonicalParamId: 'tableId', placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)', - dependsOn: ['credential', 'baseId'], + mode: 'advanced', condition: { field: 'operation', value: ['listBases', 'listTables'], not: true }, required: { field: 'operation', value: ['listBases', 'listTables'], not: true }, }, diff --git a/apps/sim/blocks/blocks/asana.ts b/apps/sim/blocks/blocks/asana.ts index 1276e8d572..2541886459 100644 --- a/apps/sim/blocks/blocks/asana.ts +++ b/apps/sim/blocks/blocks/asana.ts @@ -48,12 +48,31 @@ export const AsanaBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + id: 'workspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'workspace', + serviceId: 'asana', + selectorKey: 'asana.workspaces', + selectorAllowSearch: false, + placeholder: 'Select Asana workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['create_task', 'get_projects', 'search_tasks'], + }, + required: true, + }, { id: 'workspace', title: 'Workspace GID', type: 'short-input', + canonicalParamId: 'workspace', required: true, placeholder: 'Enter Asana workspace GID', + mode: 'advanced', condition: { field: 'operation', value: ['create_task', 'get_projects', 'search_tasks'], @@ -81,11 +100,29 @@ export const AsanaBlock: BlockConfig = { value: ['update_task', 'add_comment'], }, }, + { + id: 'getTasksWorkspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'getTasks_workspace', + serviceId: 'asana', + selectorKey: 'asana.workspaces', + selectorAllowSearch: false, + placeholder: 'Select Asana workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['get_task'], + }, + }, { id: 'getTasks_workspace', title: 'Workspace GID', type: 'short-input', + canonicalParamId: 'getTasks_workspace', placeholder: 'Enter workspace GID', + mode: 'advanced', condition: { field: 'operation', value: ['get_task'], diff --git a/apps/sim/blocks/blocks/attio.ts b/apps/sim/blocks/blocks/attio.ts index 8e51dbbe71..aebea95d36 100644 --- a/apps/sim/blocks/blocks/attio.ts +++ b/apps/sim/blocks/blocks/attio.ts @@ -86,11 +86,47 @@ export const AttioBlock: BlockConfig = { }, // Record fields + { + id: 'objectTypeSelector', + title: 'Object Type', + type: 'project-selector', + canonicalParamId: 'objectType', + serviceId: 'attio', + selectorKey: 'attio.objects', + selectorAllowSearch: false, + placeholder: 'Select object type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ], + }, + required: { + field: 'operation', + value: [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ], + }, + }, { id: 'objectType', title: 'Object Type', type: 'short-input', + canonicalParamId: 'objectType', placeholder: 'e.g. people, companies', + mode: 'advanced', condition: { field: 'operation', value: [ @@ -524,11 +560,49 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. }, // List fields + { + id: 'listSelector', + title: 'List', + type: 'project-selector', + canonicalParamId: 'listIdOrSlug', + serviceId: 'attio', + selectorKey: 'attio.lists', + selectorAllowSearch: false, + placeholder: 'Select Attio list', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ], + }, + required: { + field: 'operation', + value: [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ], + }, + }, { id: 'listIdOrSlug', title: 'List ID or Slug', type: 'short-input', + canonicalParamId: 'listIdOrSlug', placeholder: 'Enter the list ID or slug', + mode: 'advanced', condition: { field: 'operation', value: [ diff --git a/apps/sim/blocks/blocks/calcom.ts b/apps/sim/blocks/blocks/calcom.ts index a294c40b63..0a32aa854d 100644 --- a/apps/sim/blocks/blocks/calcom.ts +++ b/apps/sim/blocks/blocks/calcom.ts @@ -65,11 +65,30 @@ export const CalComBlock: BlockConfig = { }, // === Create Booking fields === + { + id: 'eventTypeSelector', + title: 'Event Type', + type: 'project-selector', + canonicalParamId: 'eventTypeId', + serviceId: 'calcom', + selectorKey: 'calcom.eventTypes', + selectorAllowSearch: false, + placeholder: 'Select event type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_create_booking', 'calcom_get_slots'], + }, + required: { field: 'operation', value: 'calcom_create_booking' }, + }, { id: 'eventTypeId', title: 'Event Type ID', type: 'short-input', + canonicalParamId: 'eventTypeId', placeholder: 'Enter event type ID (number)', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_create_booking', 'calcom_get_slots'], @@ -261,11 +280,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, // === Event Type fields === + { + id: 'eventTypeParamSelector', + title: 'Event Type', + type: 'project-selector', + canonicalParamId: 'eventTypeIdParam', + serviceId: 'calcom', + selectorKey: 'calcom.eventTypes', + selectorAllowSearch: false, + placeholder: 'Select event type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], + }, + required: { + field: 'operation', + value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], + }, + }, { id: 'eventTypeIdParam', title: 'Event Type ID', type: 'short-input', + canonicalParamId: 'eventTypeIdParam', placeholder: 'Enter event type ID', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], @@ -364,10 +405,27 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, mode: 'advanced', }, + { + id: 'eventTypeScheduleSelector', + title: 'Schedule', + type: 'project-selector', + canonicalParamId: 'eventTypeScheduleId', + serviceId: 'calcom', + selectorKey: 'calcom.schedules', + selectorAllowSearch: false, + placeholder: 'Select schedule', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_create_event_type', 'calcom_update_event_type'], + }, + }, { id: 'eventTypeScheduleId', title: 'Schedule ID', type: 'short-input', + canonicalParamId: 'eventTypeScheduleId', placeholder: 'Assign to a specific schedule', condition: { field: 'operation', @@ -388,11 +446,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, // === Schedule fields === + { + id: 'scheduleSelector', + title: 'Schedule', + type: 'project-selector', + canonicalParamId: 'scheduleId', + serviceId: 'calcom', + selectorKey: 'calcom.schedules', + selectorAllowSearch: false, + placeholder: 'Select schedule', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], + }, + required: { + field: 'operation', + value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], + }, + }, { id: 'scheduleId', title: 'Schedule ID', type: 'short-input', + canonicalParamId: 'scheduleId', placeholder: 'Enter schedule ID', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], @@ -771,7 +851,10 @@ Return ONLY valid JSON - no explanations.`, cancellationReason: { type: 'string', description: 'Reason for cancellation' }, reschedulingReason: { type: 'string', description: 'Reason for rescheduling' }, bookingStatus: { type: 'string', description: 'Filter by booking status' }, - eventTypeIdParam: { type: 'number', description: 'Event type ID for get/update/delete' }, + eventTypeIdParam: { + type: 'number', + description: 'Event type ID for get/update/delete', + }, title: { type: 'string', description: 'Event type title' }, slug: { type: 'string', description: 'URL-friendly slug' }, eventLength: { type: 'number', description: 'Event duration in minutes' }, diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 180477fd04..b71bc653e0 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -84,7 +84,6 @@ export const ConfluenceBlock: BlockConfig = { 'write:content.property:confluence', 'read:hierarchical-content:confluence', 'read:content.metadata:confluence', - 'read:user:confluence', ], placeholder: 'Select Confluence account', required: true, @@ -645,11 +644,44 @@ export const ConfluenceV2Block: BlockConfig = { ], }, }, + { + id: 'spaceSelector', + title: 'Space', + type: 'project-selector', + canonicalParamId: 'spaceId', + serviceId: 'confluence', + selectorKey: 'confluence.spaces', + selectorAllowSearch: false, + placeholder: 'Select Confluence space', + dependsOn: ['credential', 'domain'], + mode: 'basic', + required: true, + condition: { + field: 'operation', + value: [ + 'create', + 'get_space', + 'update_space', + 'delete_space', + 'list_pages_in_space', + 'search_in_space', + 'create_blogpost', + 'list_blogposts_in_space', + 'list_space_labels', + 'list_space_permissions', + 'list_space_properties', + 'create_space_property', + 'delete_space_property', + ], + }, + }, { id: 'spaceId', title: 'Space ID', type: 'short-input', + canonicalParamId: 'spaceId', placeholder: 'Enter Confluence space ID', + mode: 'advanced', required: true, condition: { field: 'operation', @@ -1250,7 +1282,6 @@ export const ConfluenceV2Block: BlockConfig = { ...rest } = params - // Use canonical param (serializer already handles basic/advanced mode) const effectivePageId = pageId ? String(pageId).trim() : '' if (operation === 'add_label') { @@ -1511,7 +1542,7 @@ export const ConfluenceV2Block: BlockConfig = { operation: { type: 'string', description: 'Operation to perform' }, domain: { type: 'string', description: 'Confluence domain' }, oauthCredential: { type: 'string', description: 'Confluence access token' }, - pageId: { type: 'string', description: 'Page identifier (canonical param)' }, + pageId: { type: 'string', description: 'Page identifier' }, spaceId: { type: 'string', description: 'Space identifier' }, blogPostId: { type: 'string', description: 'Blog post identifier' }, versionNumber: { type: 'number', description: 'Page version number' }, diff --git a/apps/sim/blocks/blocks/google_bigquery.ts b/apps/sim/blocks/blocks/google_bigquery.ts index 0ba15dfe56..1fdece8231 100644 --- a/apps/sim/blocks/blocks/google_bigquery.ts +++ b/apps/sim/blocks/blocks/google_bigquery.ts @@ -109,20 +109,52 @@ Return ONLY the SQL query - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'query' }, }, + { + id: 'datasetSelector', + title: 'Dataset', + type: 'project-selector', + canonicalParamId: 'datasetId', + serviceId: 'google-bigquery', + selectorKey: 'bigquery.datasets', + selectorAllowSearch: false, + placeholder: 'Select BigQuery dataset', + dependsOn: ['credential', 'projectId'], + mode: 'basic', + condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, + required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, + }, { id: 'datasetId', title: 'Dataset ID', type: 'short-input', + canonicalParamId: 'datasetId', placeholder: 'Enter BigQuery dataset ID', + mode: 'advanced', condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, }, + { + id: 'tableSelector', + title: 'Table', + type: 'file-selector', + canonicalParamId: 'tableId', + serviceId: 'google-bigquery', + selectorKey: 'bigquery.tables', + selectorAllowSearch: false, + placeholder: 'Select BigQuery table', + dependsOn: ['credential', 'projectId', 'datasetSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['get_table', 'insert_rows'] }, + required: { field: 'operation', value: ['get_table', 'insert_rows'] }, + }, { id: 'tableId', title: 'Table ID', type: 'short-input', + canonicalParamId: 'tableId', placeholder: 'Enter BigQuery table ID', + mode: 'advanced', condition: { field: 'operation', value: ['get_table', 'insert_rows'] }, required: { field: 'operation', value: ['get_table', 'insert_rows'] }, }, diff --git a/apps/sim/blocks/blocks/google_tasks.ts b/apps/sim/blocks/blocks/google_tasks.ts index 850f824d50..ad63e6e1a7 100644 --- a/apps/sim/blocks/blocks/google_tasks.ts +++ b/apps/sim/blocks/blocks/google_tasks.ts @@ -51,12 +51,27 @@ export const GoogleTasksBlock: BlockConfig = { required: true, }, - // Task List ID - shown for all task operations (not list_task_lists) + // Task List - shown for all task operations (not list_task_lists) + { + id: 'taskListSelector', + title: 'Task List', + type: 'project-selector', + canonicalParamId: 'taskListId', + serviceId: 'google-tasks', + selectorKey: 'google.tasks.lists', + selectorAllowSearch: false, + placeholder: 'Select task list', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: 'list_task_lists', not: true }, + }, { id: 'taskListId', title: 'Task List ID', type: 'short-input', + canonicalParamId: 'taskListId', placeholder: 'Task list ID (leave empty for default list)', + mode: 'advanced', condition: { field: 'operation', value: 'list_task_lists', not: true }, }, @@ -210,7 +225,9 @@ Return ONLY the timestamp - no explanations, no extra text.`, params: (params) => { const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params - const processedParams: Record = { ...rest } + const processedParams: Record = { + ...rest, + } if (maxResults && typeof maxResults === 'string') { processedParams.maxResults = Number.parseInt(maxResults, 10) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index e1a1ae2da2..916f0b2bd1 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -107,10 +107,16 @@ export const JiraServiceManagementBlock: BlockConfig = { required: true, }, { - id: 'serviceDeskId', - title: 'Service Desk ID', - type: 'short-input', - placeholder: 'Enter service desk ID', + id: 'serviceDeskSelector', + title: 'Service Desk', + type: 'project-selector', + canonicalParamId: 'serviceDeskId', + serviceId: 'jira', + selectorKey: 'jsm.serviceDesks', + selectorAllowSearch: false, + placeholder: 'Select service desk', + dependsOn: ['credential', 'domain'], + mode: 'basic', required: { field: 'operation', value: [ @@ -139,12 +145,63 @@ export const JiraServiceManagementBlock: BlockConfig = { ], }, }, + { + id: 'serviceDeskId', + title: 'Service Desk ID', + type: 'short-input', + canonicalParamId: 'serviceDeskId', + placeholder: 'Enter service desk ID', + mode: 'advanced', + required: { + field: 'operation', + value: [ + 'get_request_types', + 'create_request', + 'get_customers', + 'add_customer', + 'get_organizations', + 'add_organization', + 'get_queues', + 'get_request_type_fields', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_request_types', + 'create_request', + 'get_customers', + 'add_customer', + 'get_organizations', + 'add_organization', + 'get_queues', + 'get_requests', + 'get_request_type_fields', + ], + }, + }, + { + id: 'requestTypeSelector', + title: 'Request Type', + type: 'file-selector', + canonicalParamId: 'requestTypeId', + serviceId: 'jira', + selectorKey: 'jsm.requestTypes', + selectorAllowSearch: false, + placeholder: 'Select request type', + dependsOn: ['credential', 'domain', 'serviceDeskSelector'], + mode: 'basic', + required: true, + condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] }, + }, { id: 'requestTypeId', title: 'Request Type ID', type: 'short-input', + canonicalParamId: 'requestTypeId', required: true, placeholder: 'Enter request type ID', + mode: 'advanced', condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] }, }, { diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index a73d01b48d..ab90c17923 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -84,12 +84,36 @@ export const MicrosoftPlannerBlock: BlockConfig = { placeholder: 'Enter credential ID', }, - // Plan ID - for various operations + // Plan selector - basic mode + { + id: 'planSelector', + title: 'Plan', + type: 'project-selector', + canonicalParamId: 'planId', + serviceId: 'microsoft-planner', + selectorKey: 'microsoft.planner.plans', + selectorAllowSearch: false, + placeholder: 'Select a plan', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], + }, + required: { + field: 'operation', + value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'], + }, + }, + + // Plan ID - advanced mode { id: 'planId', title: 'Plan ID', type: 'short-input', + canonicalParamId: 'planId', placeholder: 'Enter the plan ID', + mode: 'advanced', condition: { field: 'operation', value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], @@ -110,7 +134,7 @@ export const MicrosoftPlannerBlock: BlockConfig = { serviceId: 'microsoft-planner', selectorKey: 'microsoft.planner', condition: { field: 'operation', value: ['read_task'] }, - dependsOn: ['credential', 'planId'], + dependsOn: ['credential', 'planSelector'], mode: 'basic', canonicalParamId: 'readTaskId', }, diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 92727444d9..f222565fdf 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -53,15 +53,49 @@ export const NotionBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, - // Read/Write operation - Page ID + { + id: 'pageSelector', + title: 'Page', + type: 'file-selector', + canonicalParamId: 'pageId', + serviceId: 'notion', + selectorKey: 'notion.pages', + placeholder: 'Select Notion page', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['notion_read', 'notion_write'], + }, + required: true, + }, { id: 'pageId', title: 'Page ID', type: 'short-input', + canonicalParamId: 'pageId', placeholder: 'Enter Notion page ID', + mode: 'advanced', condition: { field: 'operation', - value: 'notion_read', + value: ['notion_read', 'notion_write'], + }, + required: true, + }, + { + id: 'databaseSelector', + title: 'Database', + type: 'project-selector', + canonicalParamId: 'databaseId', + serviceId: 'notion', + selectorKey: 'notion.databases', + selectorAllowSearch: false, + placeholder: 'Select Notion database', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, @@ -69,31 +103,36 @@ export const NotionBlock: BlockConfig = { id: 'databaseId', title: 'Database ID', type: 'short-input', + canonicalParamId: 'databaseId', placeholder: 'Enter Notion database ID', + mode: 'advanced', condition: { field: 'operation', - value: 'notion_read_database', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, { - id: 'pageId', - title: 'Page ID', - type: 'short-input', - placeholder: 'Enter Notion page ID', - condition: { - field: 'operation', - value: 'notion_write', - }, + id: 'parentSelector', + title: 'Parent Page', + type: 'file-selector', + canonicalParamId: 'parentId', + serviceId: 'notion', + selectorKey: 'notion.pages', + placeholder: 'Select parent page', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] }, required: true, }, - // Create operation fields { id: 'parentId', title: 'Parent Page ID', type: 'short-input', + canonicalParamId: 'parentId', placeholder: 'ID of parent page', - condition: { field: 'operation', value: 'notion_create_page' }, + mode: 'advanced', + condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] }, required: true, }, { @@ -148,14 +187,6 @@ export const NotionBlock: BlockConfig = { }, }, // Query Database Fields - { - id: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', - condition: { field: 'operation', value: 'notion_query_database' }, - required: true, - }, { id: 'filter', title: 'Filter', @@ -218,14 +249,6 @@ export const NotionBlock: BlockConfig = { condition: { field: 'operation', value: 'notion_search' }, }, // Create Database Fields - { - id: 'parentId', - title: 'Parent Page ID', - type: 'short-input', - placeholder: 'ID of parent page where database will be created', - condition: { field: 'operation', value: 'notion_create_database' }, - required: true, - }, { id: 'title', title: 'Database Title', @@ -256,14 +279,6 @@ export const NotionBlock: BlockConfig = { }, }, // Add Database Row Fields - { - id: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', - condition: { field: 'operation', value: 'notion_add_database_row' }, - required: true, - }, { id: 'properties', title: 'Row Properties', @@ -404,6 +419,7 @@ export const NotionBlock: BlockConfig = { } // V2 Block with API-aligned outputs + export const NotionV2Block: BlockConfig = { type: 'notion_v2', name: 'Notion', diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index 543a6d0de3..55e5c331ff 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -96,12 +96,35 @@ export const PipedriveBlock: BlockConfig = { placeholder: 'Filter by organization ID', condition: { field: 'operation', value: ['get_all_deals'] }, }, + { + id: 'pipelineSelector', + title: 'Pipeline', + type: 'project-selector', + canonicalParamId: 'pipeline_id', + serviceId: 'pipedrive', + selectorKey: 'pipedrive.pipelines', + selectorAllowSearch: false, + placeholder: 'Select pipeline', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'], + }, + required: { field: 'operation', value: 'get_pipeline_deals' }, + }, { id: 'pipeline_id', title: 'Pipeline ID', type: 'short-input', - placeholder: 'Filter by pipeline ID ', - condition: { field: 'operation', value: ['get_all_deals'] }, + canonicalParamId: 'pipeline_id', + placeholder: 'Enter pipeline ID', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'], + }, + required: { field: 'operation', value: 'get_pipeline_deals' }, }, { id: 'updated_since', @@ -174,13 +197,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, placeholder: 'Associated organization ID ', condition: { field: 'operation', value: ['create_deal'] }, }, - { - id: 'pipeline_id', - title: 'Pipeline ID', - type: 'short-input', - placeholder: 'Pipeline ID ', - condition: { field: 'operation', value: ['create_deal'] }, - }, { id: 'stage_id', title: 'Stage ID', @@ -329,14 +345,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ], }, }, - { - id: 'pipeline_id', - title: 'Pipeline ID', - type: 'short-input', - placeholder: 'Enter pipeline ID', - required: true, - condition: { field: 'operation', value: ['get_pipeline_deals'] }, - }, { id: 'stage_id', title: 'Stage ID', diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index af54dc2f75..2bfc08bcd8 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -112,12 +112,26 @@ export const SharepointBlock: BlockConfig = { mode: 'advanced', }, + { + id: 'listSelector', + title: 'List', + type: 'file-selector', + canonicalParamId: 'listId', + serviceId: 'sharepoint', + selectorKey: 'sharepoint.lists', + selectorAllowSearch: false, + placeholder: 'Select a list', + dependsOn: ['credential', 'siteSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, + }, { id: 'listId', title: 'List ID', type: 'short-input', - placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.', canonicalParamId: 'listId', + placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.', + mode: 'advanced', condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, }, @@ -425,7 +439,9 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeColumns, includeItems, files, // canonical param from uploadFiles (basic) or files (advanced) + driveId, // canonical param from driveId columnDefinitions, + listId, ...others } = rest as any @@ -457,7 +473,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, try { logger.info('SharepointBlock list item param check', { siteId: effectiveSiteId || undefined, - listId: (others as any)?.listId, + listId: listId, listTitle: (others as any)?.listTitle, itemId: sanitizedItemId, hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object', @@ -477,6 +493,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined, mimeType: mimeType, ...others, + ...(listId ? { listId } : {}), + ...(driveId ? { driveId } : {}), itemId: sanitizedItemId, listItemFields: parsedItemFields, includeColumns: coerceBoolean(includeColumns), @@ -517,10 +535,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeItems: { type: 'boolean', description: 'Include items in response' }, itemId: { type: 'string', description: 'List item ID (canonical param)' }, listItemFields: { type: 'string', description: 'List item fields (canonical param)' }, - driveId: { type: 'string', description: 'Document library (drive) ID (canonical param)' }, + driveId: { + type: 'string', + description: 'Document library (drive) ID', + }, folderPath: { type: 'string', description: 'Folder path for file upload' }, fileName: { type: 'string', description: 'File name override' }, - files: { type: 'array', description: 'Files to upload (canonical param)' }, + files: { type: 'array', description: 'Files to upload' }, }, outputs: { sites: { diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 777e060fe9..bff115ebdc 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -59,26 +59,50 @@ export const TrelloBlock: BlockConfig = { }, { - id: 'boardId', + id: 'boardSelector', title: 'Board', - type: 'short-input', - placeholder: 'Enter board ID', + type: 'project-selector', + canonicalParamId: 'boardId', + serviceId: 'trello', + selectorKey: 'trello.boards', + selectorAllowSearch: false, + placeholder: 'Select Trello board', + dependsOn: ['credential'], + mode: 'basic', condition: { field: 'operation', - value: 'trello_list_lists', + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_create_card', + 'trello_get_actions', + ], + }, + required: { + field: 'operation', + value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], }, - required: true, }, { id: 'boardId', - title: 'Board', + title: 'Board ID', type: 'short-input', - placeholder: 'Enter board ID or search for a board', + canonicalParamId: 'boardId', + placeholder: 'Enter board ID', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_list_cards', + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_create_card', + 'trello_get_actions', + ], + }, + required: { + field: 'operation', + value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], }, - required: true, }, { id: 'listId', @@ -90,17 +114,6 @@ export const TrelloBlock: BlockConfig = { value: 'trello_list_cards', }, }, - { - id: 'boardId', - title: 'Board', - type: 'short-input', - placeholder: 'Enter board ID or search for a board', - condition: { - field: 'operation', - value: 'trello_create_card', - }, - required: true, - }, { id: 'listId', title: 'List', @@ -278,16 +291,6 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex }, }, - { - id: 'boardId', - title: 'Board ID', - type: 'short-input', - placeholder: 'Enter board ID to get board actions', - condition: { - field: 'operation', - value: 'trello_get_actions', - }, - }, { id: 'cardId', title: 'Card ID', diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 711ab3f768..9b74422ae2 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -77,12 +77,39 @@ export const ZoomBlock: BlockConfig = { value: ['zoom_create_meeting', 'zoom_list_meetings', 'zoom_list_recordings'], }, }, - // Meeting ID for get/update/delete/invitation/recordings/participants operations + // Meeting selector for get/update/delete/invitation/recordings/participants operations + { + id: 'meetingSelector', + title: 'Meeting', + type: 'project-selector', + canonicalParamId: 'meetingId', + serviceId: 'zoom', + selectorKey: 'zoom.meetings', + selectorAllowSearch: true, + placeholder: 'Select Zoom meeting', + dependsOn: ['credential'], + mode: 'basic', + required: true, + condition: { + field: 'operation', + value: [ + 'zoom_get_meeting', + 'zoom_update_meeting', + 'zoom_delete_meeting', + 'zoom_get_meeting_invitation', + 'zoom_get_meeting_recordings', + 'zoom_delete_recording', + 'zoom_list_past_participants', + ], + }, + }, { id: 'meetingId', title: 'Meeting ID', type: 'short-input', + canonicalParamId: 'meetingId', placeholder: 'Enter meeting ID', + mode: 'advanced', required: true, condition: { field: 'operation', @@ -114,7 +141,6 @@ export const ZoomBlock: BlockConfig = { title: 'Topic', type: 'short-input', placeholder: 'Meeting topic (optional)', - mode: 'advanced', condition: { field: 'operation', value: ['zoom_update_meeting'], diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 6b0674b759..db0d6b28f0 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -10,6 +10,29 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const SELECTOR_STALE = 60 * 1000 +type AirtableBase = { id: string; name: string } +type AirtableTable = { id: string; name: string } +type AsanaWorkspace = { id: string; name: string } +type AttioObject = { id: string; name: string } +type AttioList = { id: string; name: string } +type BigQueryDataset = { + datasetReference: { datasetId: string; projectId: string } + friendlyName?: string +} +type BigQueryTable = { tableReference: { tableId: string }; friendlyName?: string } +type CalcomEventType = { id: string; title: string; slug: string } +type ConfluenceSpace = { id: string; name: string; key: string } +type JsmServiceDesk = { id: string; name: string } +type JsmRequestType = { id: string; name: string } +type NotionDatabase = { id: string; name: string } +type NotionPage = { id: string; name: string } +type PipedrivePipeline = { id: string; name: string } +type ZoomMeeting = { id: string; name: string } +type CalcomSchedule = { id: string; name: string } +type GoogleTaskList = { id: string; title: string } +type PlannerPlan = { id: string; title: string } +type SharepointList = { id: string; displayName: string } +type TrelloBoard = { id: string; name: string; closed?: boolean } type SlackChannel = { id: string; name: string } type SlackUser = { id: string; name: string; real_name: string } type FolderResponse = { id: string; name: string } @@ -37,6 +60,768 @@ const ensureKnowledgeBase = (context: SelectorContext): string => { } const registry: Record = { + 'airtable.bases': { + key: 'airtable.bases', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'airtable.bases', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'airtable.bases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ bases: AirtableBase[] }>('/api/tools/airtable/bases', { + method: 'POST', + body, + }) + return (data.bases || []).map((base) => ({ + id: base.id, + label: base.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'airtable.bases') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: detailId, + }) + const data = await fetchJson<{ bases: AirtableBase[] }>('/api/tools/airtable/bases', { + method: 'POST', + body, + }) + const base = (data.bases || []).find((b) => b.id === detailId) ?? null + if (!base) return null + return { id: base.id, label: base.name } + }, + }, + 'airtable.tables': { + key: 'airtable.tables', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'airtable.tables', + context.credentialId ?? 'none', + context.baseId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.baseId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'airtable.tables') + if (!context.baseId) { + throw new Error('Missing base ID for airtable.tables selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: context.baseId, + }) + const data = await fetchJson<{ tables: AirtableTable[] }>('/api/tools/airtable/tables', { + method: 'POST', + body, + }) + return (data.tables || []).map((table) => ({ + id: table.id, + label: table.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'airtable.tables') + if (!context.baseId) return null + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: context.baseId, + }) + const data = await fetchJson<{ tables: AirtableTable[] }>('/api/tools/airtable/tables', { + method: 'POST', + body, + }) + const table = (data.tables || []).find((t) => t.id === detailId) ?? null + if (!table) return null + return { id: table.id, label: table.name } + }, + }, + 'asana.workspaces': { + key: 'asana.workspaces', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'asana.workspaces', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'asana.workspaces') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ workspaces: AsanaWorkspace[] }>( + '/api/tools/asana/workspaces', + { method: 'POST', body } + ) + return (data.workspaces || []).map((ws) => ({ id: ws.id, label: ws.name })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'asana.workspaces') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ workspaces: AsanaWorkspace[] }>( + '/api/tools/asana/workspaces', + { method: 'POST', body } + ) + const ws = (data.workspaces || []).find((w) => w.id === detailId) ?? null + if (!ws) return null + return { id: ws.id, label: ws.name } + }, + }, + 'attio.objects': { + key: 'attio.objects', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'attio.objects', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'attio.objects') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ objects: AttioObject[] }>('/api/tools/attio/objects', { + method: 'POST', + body, + }) + return (data.objects || []).map((obj) => ({ + id: obj.id, + label: obj.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'attio.objects') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ objects: AttioObject[] }>('/api/tools/attio/objects', { + method: 'POST', + body, + }) + const obj = (data.objects || []).find((o) => o.id === detailId) ?? null + if (!obj) return null + return { id: obj.id, label: obj.name } + }, + }, + 'attio.lists': { + key: 'attio.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'attio.lists', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'attio.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ lists: AttioList[] }>('/api/tools/attio/lists', { + method: 'POST', + body, + }) + return (data.lists || []).map((list) => ({ + id: list.id, + label: list.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'attio.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ lists: AttioList[] }>('/api/tools/attio/lists', { + method: 'POST', + body, + }) + const list = (data.lists || []).find((l) => l.id === detailId) ?? null + if (!list) return null + return { id: list.id, label: list.name } + }, + }, + 'bigquery.datasets': { + key: 'bigquery.datasets', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'bigquery.datasets', + context.credentialId ?? 'none', + context.projectId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.projectId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'bigquery.datasets') + if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + }) + const data = await fetchJson<{ datasets: BigQueryDataset[] }>( + '/api/tools/google_bigquery/datasets', + { method: 'POST', body } + ) + return (data.datasets || []).map((ds) => ({ + id: ds.datasetReference.datasetId, + label: ds.friendlyName || ds.datasetReference.datasetId, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.projectId) return null + const credentialId = ensureCredential(context, 'bigquery.datasets') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + }) + const data = await fetchJson<{ datasets: BigQueryDataset[] }>( + '/api/tools/google_bigquery/datasets', + { method: 'POST', body } + ) + const ds = + (data.datasets || []).find((d) => d.datasetReference.datasetId === detailId) ?? null + if (!ds) return null + return { + id: ds.datasetReference.datasetId, + label: ds.friendlyName || ds.datasetReference.datasetId, + } + }, + }, + 'bigquery.tables': { + key: 'bigquery.tables', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'bigquery.tables', + context.credentialId ?? 'none', + context.projectId ?? 'none', + context.datasetId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.credentialId && context.projectId && context.datasetId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'bigquery.tables') + if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector') + if (!context.datasetId) throw new Error('Missing dataset ID for bigquery.tables selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + datasetId: context.datasetId, + }) + const data = await fetchJson<{ tables: BigQueryTable[] }>( + '/api/tools/google_bigquery/tables', + { method: 'POST', body } + ) + return (data.tables || []).map((t) => ({ + id: t.tableReference.tableId, + label: t.friendlyName || t.tableReference.tableId, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.projectId || !context.datasetId) return null + const credentialId = ensureCredential(context, 'bigquery.tables') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + datasetId: context.datasetId, + }) + const data = await fetchJson<{ tables: BigQueryTable[] }>( + '/api/tools/google_bigquery/tables', + { method: 'POST', body } + ) + const t = (data.tables || []).find((tbl) => tbl.tableReference.tableId === detailId) ?? null + if (!t) return null + return { id: t.tableReference.tableId, label: t.friendlyName || t.tableReference.tableId } + }, + }, + 'calcom.eventTypes': { + key: 'calcom.eventTypes', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'calcom.eventTypes', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'calcom.eventTypes') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ eventTypes: CalcomEventType[] }>( + '/api/tools/calcom/event-types', + { method: 'POST', body } + ) + return (data.eventTypes || []).map((et) => ({ + id: et.id, + label: et.title || et.slug, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'calcom.eventTypes') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ eventTypes: CalcomEventType[] }>( + '/api/tools/calcom/event-types', + { method: 'POST', body } + ) + const et = (data.eventTypes || []).find((e) => e.id === detailId) ?? null + if (!et) return null + return { id: et.id, label: et.title || et.slug } + }, + }, + 'calcom.schedules': { + key: 'calcom.schedules', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'calcom.schedules', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'calcom.schedules') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ schedules: CalcomSchedule[] }>('/api/tools/calcom/schedules', { + method: 'POST', + body, + }) + return (data.schedules || []).map((s) => ({ + id: s.id, + label: s.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'calcom.schedules') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ schedules: CalcomSchedule[] }>('/api/tools/calcom/schedules', { + method: 'POST', + body, + }) + const s = (data.schedules || []).find((sc) => sc.id === detailId) ?? null + if (!s) return null + return { id: s.id, label: s.name } + }, + }, + 'confluence.spaces': { + key: 'confluence.spaces', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'confluence.spaces', + context.credentialId ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.domain), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'confluence.spaces') + const domain = ensureDomain(context, 'confluence.spaces') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ spaces: ConfluenceSpace[] }>( + '/api/tools/confluence/selector-spaces', + { method: 'POST', body } + ) + return (data.spaces || []).map((space) => ({ + id: space.id, + label: `${space.name} (${space.key})`, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'confluence.spaces') + const domain = ensureDomain(context, 'confluence.spaces') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ spaces: ConfluenceSpace[] }>( + '/api/tools/confluence/selector-spaces', + { method: 'POST', body } + ) + const space = (data.spaces || []).find((s) => s.id === detailId) ?? null + if (!space) return null + return { id: space.id, label: `${space.name} (${space.key})` } + }, + }, + 'jsm.serviceDesks': { + key: 'jsm.serviceDesks', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'jsm.serviceDesks', + context.credentialId ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.domain), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'jsm.serviceDesks') + const domain = ensureDomain(context, 'jsm.serviceDesks') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>( + '/api/tools/jsm/selector-servicedesks', + { method: 'POST', body } + ) + return (data.serviceDesks || []).map((sd) => ({ + id: sd.id, + label: sd.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'jsm.serviceDesks') + const domain = ensureDomain(context, 'jsm.serviceDesks') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>( + '/api/tools/jsm/selector-servicedesks', + { method: 'POST', body } + ) + const sd = (data.serviceDesks || []).find((s) => s.id === detailId) ?? null + if (!sd) return null + return { id: sd.id, label: sd.name } + }, + }, + 'jsm.requestTypes': { + key: 'jsm.requestTypes', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'jsm.requestTypes', + context.credentialId ?? 'none', + context.domain ?? 'none', + context.serviceDeskId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.credentialId && context.domain && context.serviceDeskId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'jsm.requestTypes') + const domain = ensureDomain(context, 'jsm.requestTypes') + if (!context.serviceDeskId) throw new Error('Missing serviceDeskId for jsm.requestTypes') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + serviceDeskId: context.serviceDeskId, + }) + const data = await fetchJson<{ requestTypes: JsmRequestType[] }>( + '/api/tools/jsm/selector-requesttypes', + { method: 'POST', body } + ) + return (data.requestTypes || []).map((rt) => ({ + id: rt.id, + label: rt.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'jsm.requestTypes') + const domain = ensureDomain(context, 'jsm.requestTypes') + if (!context.serviceDeskId) return null + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + serviceDeskId: context.serviceDeskId, + }) + const data = await fetchJson<{ requestTypes: JsmRequestType[] }>( + '/api/tools/jsm/selector-requesttypes', + { method: 'POST', body } + ) + const rt = (data.requestTypes || []).find((r) => r.id === detailId) ?? null + if (!rt) return null + return { id: rt.id, label: rt.name } + }, + }, + 'google.tasks.lists': { + key: 'google.tasks.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'google.tasks.lists', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'google.tasks.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ taskLists: GoogleTaskList[] }>( + '/api/tools/google_tasks/task-lists', + { method: 'POST', body } + ) + return (data.taskLists || []).map((tl) => ({ id: tl.id, label: tl.title })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'google.tasks.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ taskLists: GoogleTaskList[] }>( + '/api/tools/google_tasks/task-lists', + { method: 'POST', body } + ) + const tl = (data.taskLists || []).find((t) => t.id === detailId) ?? null + if (!tl) return null + return { id: tl.id, label: tl.title } + }, + }, + 'microsoft.planner.plans': { + key: 'microsoft.planner.plans', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'microsoft.planner.plans', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'microsoft.planner.plans') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', { + method: 'POST', + body, + }) + return (data.plans || []).map((plan) => ({ id: plan.id, label: plan.title })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'microsoft.planner.plans') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', { + method: 'POST', + body, + }) + const plan = (data.plans || []).find((p) => p.id === detailId) ?? null + if (!plan) return null + return { id: plan.id, label: plan.title } + }, + }, + 'notion.databases': { + key: 'notion.databases', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'notion.databases', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'notion.databases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ databases: NotionDatabase[] }>('/api/tools/notion/databases', { + method: 'POST', + body, + }) + return (data.databases || []).map((db) => ({ + id: db.id, + label: db.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'notion.databases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ databases: NotionDatabase[] }>('/api/tools/notion/databases', { + method: 'POST', + body, + }) + const db = (data.databases || []).find((d) => d.id === detailId) ?? null + if (!db) return null + return { id: db.id, label: db.name } + }, + }, + 'notion.pages': { + key: 'notion.pages', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'notion.pages', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'notion.pages') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pages: NotionPage[] }>('/api/tools/notion/pages', { + method: 'POST', + body, + }) + return (data.pages || []).map((page) => ({ + id: page.id, + label: page.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'notion.pages') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pages: NotionPage[] }>('/api/tools/notion/pages', { + method: 'POST', + body, + }) + const page = (data.pages || []).find((p) => p.id === detailId) ?? null + if (!page) return null + return { id: page.id, label: page.name } + }, + }, + 'pipedrive.pipelines': { + key: 'pipedrive.pipelines', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'pipedrive.pipelines', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'pipedrive.pipelines') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pipelines: PipedrivePipeline[] }>( + '/api/tools/pipedrive/pipelines', + { method: 'POST', body } + ) + return (data.pipelines || []).map((p) => ({ + id: p.id, + label: p.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'pipedrive.pipelines') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pipelines: PipedrivePipeline[] }>( + '/api/tools/pipedrive/pipelines', + { method: 'POST', body } + ) + const p = (data.pipelines || []).find((pl) => pl.id === detailId) ?? null + if (!p) return null + return { id: p.id, label: p.name } + }, + }, + 'sharepoint.lists': { + key: 'sharepoint.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'sharepoint.lists', + context.credentialId ?? 'none', + context.siteId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'sharepoint.lists') + if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ lists: SharepointList[] }>('/api/tools/sharepoint/lists', { + method: 'POST', + body, + }) + return (data.lists || []).map((list) => ({ id: list.id, label: list.displayName })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.siteId) return null + const credentialId = ensureCredential(context, 'sharepoint.lists') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ lists: SharepointList[] }>('/api/tools/sharepoint/lists', { + method: 'POST', + body, + }) + const list = (data.lists || []).find((l) => l.id === detailId) ?? null + if (!list) return null + return { id: list.id, label: list.displayName } + }, + }, + 'trello.boards': { + key: 'trello.boards', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'trello.boards', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'trello.boards') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ boards: TrelloBoard[] }>('/api/tools/trello/boards', { + method: 'POST', + body, + }) + return (data.boards || []) + .filter((board) => !board.closed) + .map((board) => ({ id: board.id, label: board.name })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'trello.boards') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ boards: TrelloBoard[] }>('/api/tools/trello/boards', { + method: 'POST', + body, + }) + const board = (data.boards || []).find((b) => b.id === detailId) ?? null + if (!board) return null + return { id: board.id, label: board.name } + }, + }, + 'zoom.meetings': { + key: 'zoom.meetings', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'zoom.meetings', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'zoom.meetings') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ meetings: ZoomMeeting[] }>('/api/tools/zoom/meetings', { + method: 'POST', + body, + }) + return (data.meetings || []).map((m) => ({ + id: m.id, + label: m.name || `Meeting ${m.id}`, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'zoom.meetings') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ meetings: ZoomMeeting[] }>('/api/tools/zoom/meetings', { + method: 'POST', + body, + }) + const meeting = (data.meetings || []).find((m) => m.id === detailId) ?? null + if (!meeting) return null + return { id: meeting.id, label: meeting.name || `Meeting ${meeting.id}` } + }, + }, 'slack.channels': { key: 'slack.channels', staleTime: SELECTOR_STALE, @@ -242,10 +1027,16 @@ const registry: Record = { ], enabled: ({ context }) => Boolean(context.credentialId), fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'sharepoint.sites') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + }) const data = await fetchJson<{ files: { id: string; name: string }[] }>( '/api/tools/sharepoint/sites', { - searchParams: { credentialId: context.credentialId }, + method: 'POST', + body, } ) return (data.files || []).map((file) => ({ @@ -253,6 +1044,24 @@ const registry: Record = { label: file.name, })) }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'sharepoint.sites') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + }) + const data = await fetchJson<{ files: { id: string; name: string }[] }>( + '/api/tools/sharepoint/sites', + { + method: 'POST', + body, + } + ) + const site = (data.files || []).find((f) => f.id === detailId) ?? null + if (!site) return null + return { id: site.id, label: site.name } + }, }, 'microsoft.planner': { key: 'microsoft.planner', @@ -265,17 +1074,37 @@ const registry: Record = { ], enabled: ({ context }) => Boolean(context.credentialId && context.planId), fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'microsoft.planner') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + planId: context.planId, + }) const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', { - searchParams: { - credentialId: context.credentialId, - planId: context.planId, - }, + method: 'POST', + body, }) return (data.tasks || []).map((task) => ({ id: task.id, label: task.title, })) }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'microsoft.planner') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + planId: context.planId, + }) + const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', { + method: 'POST', + body, + }) + const task = (data.tasks || []).find((t) => t.id === detailId) ?? null + if (!task) return null + return { id: task.id, label: task.title } + }, }, 'jira.projects': { key: 'jira.projects', diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index 9f299d99d8..81986860ad 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -18,6 +18,10 @@ export interface SelectorResolutionArgs { siteId?: string collectionId?: string spreadsheetId?: string + fileId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } export function resolveSelectorForSubBlock( @@ -38,6 +42,10 @@ export function resolveSelectorForSubBlock( siteId: args.siteId, collectionId: args.collectionId, spreadsheetId: args.spreadsheetId, + fileId: args.fileId, + baseId: args.baseId, + datasetId: args.datasetId, + serviceDeskId: args.serviceDeskId, mimeType: subBlock.mimeType, }, allowSearch: subBlock.selectorAllowSearch ?? true, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index b884a47191..8f8beee32e 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -2,6 +2,26 @@ import type React from 'react' import type { QueryKey } from '@tanstack/react-query' export type SelectorKey = + | 'airtable.bases' + | 'airtable.tables' + | 'asana.workspaces' + | 'attio.lists' + | 'attio.objects' + | 'bigquery.datasets' + | 'bigquery.tables' + | 'calcom.eventTypes' + | 'calcom.schedules' + | 'confluence.spaces' + | 'google.tasks.lists' + | 'jsm.requestTypes' + | 'jsm.serviceDesks' + | 'microsoft.planner.plans' + | 'notion.databases' + | 'notion.pages' + | 'pipedrive.pipelines' + | 'sharepoint.lists' + | 'trello.boards' + | 'zoom.meetings' | 'slack.channels' | 'slack.users' | 'gmail.labels' @@ -54,6 +74,9 @@ export interface SelectorContext { collectionId?: string spreadsheetId?: string excludeWorkflowId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 85a2aab98f..4ed0770cdc 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' +import { isEnvVarReference, isReference } from '@/executor/constants' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types' @@ -35,8 +36,10 @@ export function useSelectorOptionDetail( context: args.context, detailId: args.detailId, } + const hasRealDetailId = + Boolean(args.detailId) && !isReference(args.detailId!) && !isEnvVarReference(args.detailId!) const baseEnabled = - Boolean(args.detailId) && definition.fetchById !== undefined + hasRealDetailId && definition.fetchById !== undefined ? definition.enabled ? definition.enabled(queryArgs) : true diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts index 91d6f7f817..24d2fe51eb 100644 --- a/apps/sim/hooks/use-selector-display-name.ts +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -18,6 +18,13 @@ interface SelectorDisplayNameArgs { planId?: string teamId?: string knowledgeBaseId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string + siteId?: string + collectionId?: string + spreadsheetId?: string + fileId?: string } export function useSelectorDisplayName({ @@ -30,6 +37,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }: SelectorDisplayNameArgs) { const detailId = typeof value === 'string' && value.length > 0 ? value : undefined @@ -43,6 +57,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }) }, [ subBlock, @@ -54,6 +75,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, ]) const key = resolution?.key diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index cd005277ba..a62bd65721 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -553,6 +553,51 @@ export function validateMicrosoftGraphId( return { isValid: true, sanitized: value } } +/** + * Validates SharePoint site IDs used in Microsoft Graph API. + * + * Site IDs are compound identifiers: `hostname,spsite-guid,spweb-guid` + * (e.g. `contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE`). + * The API also accepts partial forms like a single GUID or just a hostname. + * + * Allowed characters: alphanumeric, periods, hyphens, and commas. + * + * @param value - The SharePoint site ID to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateSharePointSiteId( + value: string | null | undefined, + paramName = 'siteId' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { + isValid: false, + error: `${paramName} is required`, + } + } + + if (value.length > 512) { + return { + isValid: false, + error: `${paramName} exceeds maximum length`, + } + } + + if (!/^[a-zA-Z0-9.\-,]+$/.test(value)) { + logger.warn('Invalid characters in SharePoint site ID', { + paramName, + value: value.substring(0, 100), + }) + return { + isValid: false, + error: `${paramName} contains invalid characters`, + } + } + + return { isValid: true, sanitized: value } +} + /** * Validates Jira Cloud IDs (typically UUID format) * diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 4912654023..9a041e7cc9 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -52,6 +52,9 @@ interface ExtendedSelectorContext { siteId?: string collectionId?: string spreadsheetId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string { @@ -163,6 +166,9 @@ async function resolveSelectorValue( siteId: extendedContext.siteId, collectionId: extendedContext.collectionId, spreadsheetId: extendedContext.spreadsheetId, + baseId: extendedContext.baseId, + datasetId: extendedContext.datasetId, + serviceDeskId: extendedContext.serviceDeskId, } if (definition.fetchById) { @@ -240,6 +246,9 @@ function extractExtendedContext( siteId: getStringValue('siteId'), collectionId: getStringValue('collectionId'), spreadsheetId: getStringValue('spreadsheetId') || getStringValue('fileId'), + baseId: getStringValue('baseId') || getStringValue('baseSelector'), + datasetId: getStringValue('datasetId') || getStringValue('datasetSelector'), + serviceDeskId: getStringValue('serviceDeskId') || getStringValue('serviceDeskSelector'), } } @@ -313,6 +322,9 @@ export async function resolveValueForDisplay( siteId: extendedContext.siteId, collectionId: extendedContext.collectionId, spreadsheetId: extendedContext.spreadsheetId, + baseId: extendedContext.baseId, + datasetId: extendedContext.datasetId, + serviceDeskId: extendedContext.serviceDeskId, }) if (resolution?.key) {