diff --git a/apps/sim/app/api/tools/pinterest/boards/route.ts b/apps/sim/app/api/tools/pinterest/boards/route.ts new file mode 100644 index 0000000000..b2a87381bc --- /dev/null +++ b/apps/sim/app/api/tools/pinterest/boards/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PinterestBoardsAPI') + +interface PinterestBoard { + id: string + name: string + description?: string + privacy?: string + owner?: { + username: string + } +} + +export async function POST(request: Request) { + try { + const requestId = generateRequestId() + 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 } + ) + } + + logger.info('Fetching Pinterest boards', { requestId }) + + const response = await fetch('https://api.pinterest.com/v5/boards', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Pinterest API error', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { error: `Pinterest API error: ${response.status} - ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + const boards = (data.items || []).map((board: PinterestBoard) => ({ + id: board.id, + name: board.name, + description: board.description, + privacy: board.privacy, + })) + + logger.info(`Successfully fetched ${boards.length} Pinterest boards`, { requestId }) + return NextResponse.json({ items: boards }) + } catch (error) { + logger.error('Error processing Pinterest boards request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Pinterest boards', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/pinterest.ts b/apps/sim/blocks/blocks/pinterest.ts new file mode 100644 index 0000000000..7f6ce2d7e8 --- /dev/null +++ b/apps/sim/blocks/blocks/pinterest.ts @@ -0,0 +1,107 @@ +import { PinterestIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { PinterestResponse } from '@/tools/pinterest/types' + +export const PinterestBlock: BlockConfig = { + type: 'pinterest', + name: 'Pinterest', + description: 'Create pins on your Pinterest boards', + authMode: AuthMode.OAuth, + longDescription: 'Create and share pins on Pinterest. Post images with titles, descriptions, and links to your boards.', + docsLink: 'https://docs.sim.ai/tools/pinterest', + category: 'tools', + bgColor: '#E60023', + icon: PinterestIcon, + subBlocks: [ + { + id: 'credential', + title: 'Pinterest Account', + type: 'oauth-input', + serviceId: 'pinterest', + requiredScopes: ['boards:read', 'boards:write', 'pins:read', 'pins:write'], + placeholder: 'Select Pinterest account', + required: true, + }, + { + id: 'board_id', + title: 'Board', + type: 'file-selector', + canonicalParamId: 'board_id', + serviceId: 'pinterest', + placeholder: 'Select a Pinterest board', + dependsOn: ['credential'], + required: true, + }, + { + id: 'title', + title: 'Pin Title', + type: 'short-input', + placeholder: 'Enter pin title', + required: true, + }, + { + id: 'description', + title: 'Pin Description', + type: 'long-input', + placeholder: 'Enter pin description', + required: true, + }, + { + id: 'media_url', + title: 'Image URL', + type: 'short-input', + placeholder: 'Enter image URL', + required: true, + }, + { + id: 'link', + title: 'Destination Link', + type: 'short-input', + placeholder: 'Enter destination URL (optional)', + required: false, + }, + { + id: 'alt_text', + title: 'Alt Text', + type: 'short-input', + placeholder: 'Enter alt text for accessibility (optional)', + required: false, + }, + ], + tools: { + access: ['pinterest_create_pin'], + config: { + tool: () => 'pinterest_create_pin', + params: (inputs) => { + const { credential, ...rest } = inputs + + return { + accessToken: credential, + board_id: rest.board_id, + title: rest.title, + description: rest.description, + media_url: rest.media_url, + link: rest.link, + alt_text: rest.alt_text, + } + }, + }, + }, + inputs: { + credential: { type: 'string', description: 'Pinterest access token' }, + board_id: { type: 'string', description: 'Board ID where the pin will be created' }, + title: { type: 'string', description: 'Pin title' }, + description: { type: 'string', description: 'Pin description' }, + media_url: { type: 'string', description: 'Image URL for the pin' }, + link: { type: 'string', description: 'Destination link when pin is clicked' }, + alt_text: { type: 'string', description: 'Alt text for accessibility' }, + }, + outputs: { + success: { type: 'boolean', description: 'Whether the pin was created successfully' }, + pin: { type: 'json', description: 'Full pin object' }, + pin_id: { type: 'string', description: 'ID of the created pin' }, + pin_url: { type: 'string', description: 'URL of the created pin' }, + error: { type: 'string', description: 'Error message if operation failed' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 737f09bacf..c4adf833b8 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -60,6 +60,7 @@ import { KnowledgeBlock } from '@/blocks/blocks/knowledge' import { LinearBlock } from '@/blocks/blocks/linear' import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkupBlock } from '@/blocks/blocks/linkup' +import { PinterestBlock } from '@/blocks/blocks/pinterest' import { MailchimpBlock } from '@/blocks/blocks/mailchimp' import { MailgunBlock } from '@/blocks/blocks/mailgun' import { ManualTriggerBlock } from '@/blocks/blocks/manual_trigger' @@ -205,6 +206,7 @@ export const registry: Record = { linear: LinearBlock, linkedin: LinkedInBlock, linkup: LinkupBlock, + pinterest: PinterestBlock, mailchimp: MailchimpBlock, mailgun: MailgunBlock, manual_trigger: ManualTriggerBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f84923f2b7..b3564c4e4e 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4302,6 +4302,12 @@ export function SpotifyIcon(props: SVGProps) { ) } +export const PinterestIcon = (props: SVGProps) => ( + + + +) + export function GrainIcon(props: SVGProps) { return ( diff --git a/apps/sim/drizzle.config.ts b/apps/sim/drizzle.config.ts index d93370d17e..af97332b8d 100644 --- a/apps/sim/drizzle.config.ts +++ b/apps/sim/drizzle.config.ts @@ -8,4 +8,4 @@ export default { dbCredentials: { url: env.DATABASE_URL, }, -} satisfies Config +} satisfies Config \ No newline at end of file diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 39844c2979..bd681316c3 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -791,6 +791,37 @@ const registry: Record = { })) }, }, + 'pinterest.boards': { + key: 'pinterest.boards', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'pinterest.boards', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const body = JSON.stringify({ + credential: context.credentialId, + workflowId: context.workflowId, + }) + const data = await fetchJson<{ items: { id: string; name: string; description?: string; privacy?: string }[] }>( + '/api/tools/pinterest/boards', + { + method: 'POST', + body, + } + ) + return (data.items || []).map((board) => ({ + id: board.id, + label: board.name, + meta: { + description: board.description, + privacy: board.privacy, + }, + })) + }, + }, } export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index 78af03f935..127fcf3c2a 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -120,6 +120,8 @@ function resolveFileSelector( return { key: 'webflow.items', context, allowSearch: true } } return { key: null, context, allowSearch: true } + case 'pinterest': + return { key: 'pinterest.boards', context, allowSearch: true } default: return { key: null, context, allowSearch: true } } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index e9da5996a2..49962d95a5 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -27,6 +27,7 @@ export type SelectorKey = | 'webflow.sites' | 'webflow.collections' | 'webflow.items' + | 'pinterest.boards' export interface SelectorOption { id: string diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 9d9e4b4ec7..9cfeb89930 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -15,6 +15,7 @@ import { organization, } from 'better-auth/plugins' import { and, eq } from 'drizzle-orm' +import crypto from 'node:crypto' import { headers } from 'next/headers' import Stripe from 'stripe' import { @@ -273,6 +274,7 @@ export const auth = betterAuth({ 'hubspot', 'linkedin', 'spotify', + 'pinterest', // Common SSO provider patterns ...SSO_TRUSTED_PROVIDERS, @@ -1752,6 +1754,89 @@ export const auth = betterAuth({ }, }, + // Pinterest provider + { + providerId: 'pinterest', + clientId: env.PINTEREST_CLIENT_ID as string, + clientSecret: env.PINTEREST_CLIENT_SECRET as string, + authorizationUrl: 'https://www.pinterest.com/oauth/', + tokenUrl: 'https://api.pinterest.com/v5/oauth/token', + userInfoUrl: 'https://api.pinterest.com/v5/user_account', + scopes: ['boards:read', 'boards:write', 'pins:read', 'pins:write'], + responseType: 'code', + authentication: 'basic', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pinterest`, + getUserInfo: async (tokens) => { + // Generate stable ID from access token to prevent duplicate accounts + const stableId = crypto + .createHash('sha256') + .update(tokens.accessToken) + .digest('hex') + .substring(0, 16) + + try { + logger.info('Fetching Pinterest user profile', { + hasAccessToken: !!tokens.accessToken, + }) + + const response = await fetch('https://api.pinterest.com/v5/user_account', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorBody = await response.text() + logger.error('Failed to fetch Pinterest user info', { + status: response.status, + statusText: response.statusText, + body: errorBody, + }) + + // Pinterest might not require user info - return minimal data + return { + id: `pinterest_${stableId}`, + name: 'Pinterest User', + email: `pinterest_${stableId}@pinterest.user`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + } + } + + const profile = await response.json() + logger.info('Pinterest profile fetched successfully', { profile }) + + // Log warning if profile data is missing critical fields + if (!profile.username && !profile.id) { + logger.warn('Pinterest profile missing username and id', { profile }) + } + + return { + id: profile.username || profile.id || `pinterest_${stableId}`, + name: profile.username || profile.business_name || 'Pinterest User', + email: `${profile.username || profile.id || stableId}@pinterest.user`, + emailVerified: true, + image: profile.profile_image || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Pinterest getUserInfo:', { error }) + // Return fallback user info instead of null + return { + id: `pinterest_${stableId}`, + name: 'Pinterest User', + email: `pinterest_${stableId}@pinterest.user`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + } + } + }, + }, + // Zoom provider { providerId: 'zoom', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dc627c01a5..28ec1480c9 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -239,6 +239,8 @@ export const env = createEnv({ WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret + PINTEREST_CLIENT_ID: z.string().optional(), // Pinterest OAuth client ID + PINTEREST_CLIENT_SECRET: z.string().optional(), // Pinterest OAuth client secret // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 9b37265a55..787fbc8f98 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -25,6 +25,7 @@ import { MicrosoftTeamsIcon, NotionIcon, OutlookIcon, + PinterestIcon, PipedriveIcon, RedditIcon, SalesforceIcon, @@ -749,6 +750,21 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'spotify', }, + pinterest: { + name: 'Pinterest', + icon: PinterestIcon, + services: { + pinterest: { + name: 'Pinterest', + description: 'Create and manage pins on your Pinterest boards.', + providerId: 'pinterest', + icon: PinterestIcon, + baseProviderIcon: PinterestIcon, + scopes: ['boards:read', 'boards:write', 'pins:read', 'pins:write'], + }, + }, + defaultService: 'pinterest', + }, } interface ProviderAuthConfig { @@ -1065,6 +1081,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } + case 'pinterest': { + const { clientId, clientSecret } = getCredentials( + env.PINTEREST_CLIENT_ID, + env.PINTEREST_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://api.pinterest.com/v5/oauth/token', + clientId, + clientSecret, + useBasicAuth: true, + supportsRefreshTokenRotation: true, + } + } default: throw new Error(`Unsupported provider: ${provider}`) } diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 0555688813..55c1ac8695 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -41,6 +41,7 @@ export type OAuthProvider = | 'zoom' | 'wordpress' | 'spotify' + | 'pinterest' export type OAuthService = | 'google' @@ -81,6 +82,7 @@ export type OAuthService = | 'zoom' | 'wordpress' | 'spotify' + | 'pinterest' export interface OAuthProviderConfig { name: string diff --git a/apps/sim/tools/pinterest/create_pin.ts b/apps/sim/tools/pinterest/create_pin.ts new file mode 100644 index 0000000000..0450d49f6c --- /dev/null +++ b/apps/sim/tools/pinterest/create_pin.ts @@ -0,0 +1,124 @@ +import type { CreatePinParams, CreatePinResponse } from '@/tools/pinterest/types' +import type { ToolConfig } from '@/tools/types' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('PinterestCreatePin') + +export const createPinTool: ToolConfig = { + id: 'pinterest_create_pin', + name: 'Create Pinterest Pin', + description: 'Create a new pin on a Pinterest board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'pinterest', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Pinterest OAuth access token', + }, + board_id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the board to create the pin on', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The title of the pin', + }, + description: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The description of the pin', + }, + media_url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The URL of the image for the pin', + }, + link: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The destination URL when the pin is clicked (optional)', + }, + alt_text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Alt text for the image (optional)', + }, + }, + + request: { + url: 'https://api.pinterest.com/v5/pins', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + board_id: params.board_id, + title: params.title, + description: params.description, + media_source: { + source_type: 'image_url', + url: params.media_url, + }, + } + + if (params.link) { + body.link = params.link + } + + if (params.alt_text) { + body.alt_text = params.alt_text + } + + return body + }, + }, + + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Pinterest create pin failed', { + status: response.status, + statusText: response.statusText, + error: errorText, + url: response.url, + }) + return { + success: false, + output: {}, + error: `Pinterest API error: ${response.status} - ${errorText}`, + } + } + + const pin = await response.json() + logger.info('Pinterest pin created successfully', { + pinId: pin.id, + boardId: pin.board_id, + }) + + return { + success: true, + output: { + pin, + pin_id: pin.id, + pin_url: pin.link || `https://pinterest.com/pin/${pin.id}`, + }, + } + }, +} diff --git a/apps/sim/tools/pinterest/index.ts b/apps/sim/tools/pinterest/index.ts new file mode 100644 index 0000000000..d65d08b7c5 --- /dev/null +++ b/apps/sim/tools/pinterest/index.ts @@ -0,0 +1,5 @@ +import { createPinTool } from './create_pin' +import { listBoardsTool } from './list_boards' + +export const pinterestCreatePinTool = createPinTool +export const pinterestListBoardsTool = listBoardsTool diff --git a/apps/sim/tools/pinterest/list_boards.ts b/apps/sim/tools/pinterest/list_boards.ts new file mode 100644 index 0000000000..dba0ca7a6f --- /dev/null +++ b/apps/sim/tools/pinterest/list_boards.ts @@ -0,0 +1,50 @@ +import type { ToolConfig } from '@/tools/types' +import type { ListBoardsParams, ListBoardsResponse } from './types' + +/** + * Tool for listing Pinterest boards + */ +export const listBoardsTool: ToolConfig = { + id: 'pinterest_list_boards', + name: 'List Pinterest Boards', + description: 'Get a list of all boards for the authenticated Pinterest user', + version: '1.0.0', + oauth: { + required: true, + provider: 'pinterest', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Pinterest OAuth access token', + }, + }, + request: { + url: 'https://api.pinterest.com/v5/boards', + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: {}, + error: `Pinterest API error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + return { + success: true, + output: { + boards: data.items || [], + }, + } + }, +} diff --git a/apps/sim/tools/pinterest/types.ts b/apps/sim/tools/pinterest/types.ts new file mode 100644 index 0000000000..b1e8fc97c0 --- /dev/null +++ b/apps/sim/tools/pinterest/types.ts @@ -0,0 +1,88 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Parameters for creating a Pinterest pin + */ +export interface CreatePinParams { + accessToken: string + board_id: string + title: string + description: string + media_url: string + link?: string + alt_text?: string +} + +/** + * Pinterest pin object + */ +export interface PinterestPin { + id: string + created_at: string + board_id: string + title: string + description: string + link: string + media: { + images: { + [key: string]: { + width: number + height: number + url: string + } + } + } +} + +/** + * Response from creating a pin + */ +export interface CreatePinResponse extends ToolResponse { + output: { + pin?: PinterestPin + pin_id?: string + pin_url?: string + } +} + +/** + * Generic Pinterest response type for blocks + */ +export type PinterestResponse = { + success: boolean + output: { + pin?: PinterestPin + pin_id?: string + pin_url?: string + } + error?: string +} + +/** + * Pinterest board object + */ +export interface PinterestBoard { + id: string + name: string + description: string + privacy: string + owner: { + username: string + } +} + +/** + * Parameters for listing boards + */ +export interface ListBoardsParams { + accessToken: string +} + +/** + * Response from listing boards + */ +export interface ListBoardsResponse extends ToolResponse { + output: { + boards?: PinterestBoard[] + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4610808eb3..f98947fd2f 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -599,6 +599,7 @@ import { import { linkedInGetProfileTool, linkedInSharePostTool } from '@/tools/linkedin' import { linkupSearchTool } from '@/tools/linkup' import { llmChatTool } from '@/tools/llm' +import { pinterestCreatePinTool, pinterestListBoardsTool } from '@/tools/pinterest' import { mailchimpAddMemberTagsTool, mailchimpAddMemberTool, @@ -1430,6 +1431,8 @@ export const tools: Record = { linkup_search: linkupSearchTool, linkedin_share_post: linkedInSharePostTool, linkedin_get_profile: linkedInGetProfileTool, + pinterest_create_pin: pinterestCreatePinTool, + pinterest_list_boards: pinterestListBoardsTool, resend_send: mailSendTool, sendgrid_send_mail: sendGridSendMailTool, sendgrid_add_contact: sendGridAddContactTool, diff --git a/helm/sim/templates/networkpolicy.yaml b/helm/sim/templates/networkpolicy.yaml index deac5a5dba..7ef8697417 100644 --- a/helm/sim/templates/networkpolicy.yaml +++ b/helm/sim/templates/networkpolicy.yaml @@ -141,6 +141,10 @@ spec: ports: - protocol: TCP port: 443 + # Allow custom egress rules + {{- with .Values.networkPolicy.egress }} + {{- toYaml . | nindent 2 }} + {{- end }} {{- end }} {{- if .Values.postgresql.enabled }}