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 00000000000..839c1359dd3 --- /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 00000000000..41cd68dc12f --- /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 00000000000..2393ade11c9 --- /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 00000000000..1575f7eb3a0 --- /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 00000000000..ae3ba5152dd --- /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 00000000000..b8596f614f8 --- /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 00000000000..8f69328cc65 --- /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 00000000000..7ae61d3e983 --- /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 00000000000..ffc4ef7235d --- /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 00000000000..f2f7c6c43c4 --- /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 00000000000..6448f216505 --- /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 00000000000..a9ef02bec86 --- /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 00000000000..b4bc93032fb --- /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 00000000000..e43650d3d7a --- /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 eecfdb48c75..db0ccd88ae6 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' - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } +export async function POST(request: Request) { + const requestId = generateRequestId() - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const planId = searchParams.get('planId') + try { + const body = await request.json() + const { credential, workflowId, planId } = body - 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 }) - } - - 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 authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - 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 00000000000..1dee214a2d9 --- /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 00000000000..0a0bd4f4703 --- /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 00000000000..ba188e6c386 --- /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 00000000000..fbbbaab6817 --- /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 de161b97309..2119fe975c6 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 { 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 body = await request.json() + const { credential, workflowId, query } = body - 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 }) + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { 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 authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - 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 00000000000..fb4ca52738a --- /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 00000000000..01360af7610 --- /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 ff6d5e43b6d..e9acaca7dcb 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 ae10a76a029..de5694b7b9b 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 dfc80540915..9f668aab81e 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 63fc14b33dd..f2b38efb309 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 1276e8d572b..25418864593 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 8e51dbbe713..aebea95d363 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 a294c40b634..0a32aa854dc 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 180477fd044..b71bc653e0f 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 0ba15dfe565..1fdece82317 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 850f824d509..ad63e6e1a72 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 e1a1ae2da2d..916f0b2bd1e 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -106,11 +106,52 @@ export const JiraServiceManagementBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + 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: [ + '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: 'serviceDeskId', title: 'Service Desk ID', type: 'short-input', + canonicalParamId: 'serviceDeskId', placeholder: 'Enter service desk ID', + mode: 'advanced', required: { field: 'operation', value: [ @@ -139,12 +180,28 @@ export const JiraServiceManagementBlock: BlockConfig = { ], }, }, + { + 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 a73d01b48d3..ab90c179236 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 92727444d94..f222565fdf4 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -53,47 +53,86 @@ 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: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', + 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', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, { - id: 'pageId', - title: 'Page ID', + id: 'databaseId', + title: 'Database ID', type: 'short-input', - placeholder: 'Enter Notion page ID', + canonicalParamId: 'databaseId', + placeholder: 'Enter Notion database ID', + mode: 'advanced', condition: { field: 'operation', - value: 'notion_write', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, - // Create operation fields + { + 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, + }, { 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 543a6d0de34..55e5c331ff4 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 af54dc2f75a..2bfc08bcd8b 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 777e060fe9b..bff115ebdcf 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 711ab3f7681..9b74422ae2c 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 6b0674b7597..db0d6b28f04 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 9f299d99d8f..81986860adb 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 b884a471911..8f8beee32e3 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 85a2aab98fd..4ed0770cdc7 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 91d6f7f8172..24d2fe51ebe 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 cd005277ba1..a62bd657218 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 4912654023d..9a041e7cc9c 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) {