diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml new file mode 100644 index 000000000000..c1f8a94f0597 --- /dev/null +++ b/.github/workflows/helm-chart.yml @@ -0,0 +1,71 @@ +name: Helm chart + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - 'deploy/activepieces-helm/**' + - '.github/workflows/helm-chart.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + install: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: deploy/activepieces-helm + steps: + - uses: actions/checkout@v5 + + - uses: azure/setup-helm@v4 + + - name: Lint and render with default values + run: | + helm repo add mittwald https://helm.mittwald.de + helm repo add bitnami https://charts.bitnami.com/bitnami + helm dependency build + helm lint . + helm template ap . | grep -q '^kind: Rollout' + helm template ap . --api-versions apps/v1/StatefulSet | grep -q '^kind: StatefulSet' + helm template ap . --set autoscaling.enabled=true --api-versions apps/v1/StatefulSet --api-versions argoproj.io/v1alpha1/Rollout > "$RUNNER_TEMP/rollout.yaml" + grep -q '^kind: Rollout' "$RUNNER_TEMP/rollout.yaml" + grep -q 'name: ap-activepieces-preview' "$RUNNER_TEMP/rollout.yaml" + grep -A2 'scaleTargetRef:' "$RUNNER_TEMP/rollout.yaml" | grep -q 'kind: Rollout' + NOTES=$(helm install ap . --dry-run=client --set activepieces.frontendUrl=x) + grep -q 'ignores these values' <<< "$NOTES" + NOTES=$(helm install ap . --dry-run=client) + ! grep -q 'ignores these values' <<< "$NOTES" + + - uses: helm/kind-action@v1 + + - name: Create connection secrets + run: | + kubectl create secret generic activepieces-config-secrets --from-literal=AP_FRONTEND_URL=http://ap-activepieces.default.svc.cluster.local + kubectl create secret generic activepieces-db-secrets --from-literal=AP_POSTGRES_HOST=ap-postgresql --from-literal=AP_POSTGRES_PORT=5432 --from-literal=AP_POSTGRES_DATABASE=postgres --from-literal=AP_POSTGRES_USERNAME=postgres --from-literal=AP_POSTGRES_PASSWORD=ci-password + kubectl create secret generic activepieces-redis-secrets --from-literal=AP_REDIS_HOST=ap-redis-master --from-literal=AP_REDIS_PORT=6379 --from-literal=AP_REDIS_PASSWORD=ci-password + + - name: Install + run: | + helm install ap . --wait --timeout 10m \ + --set image.tag=latest \ + --set persistence.storageClassName=standard \ + --set postgresql.enabled=true,postgresql.auth.postgresPassword=ci-password \ + --set redis.enabled=true,redis.architecture=standalone,redis.auth.password=ci-password + + - name: Test + run: | + kubectl get statefulset ap-activepieces + helm test ap + + - name: Debug + if: failure() + run: | + kubectl get pods + kubectl describe pods + kubectl logs statefulset/ap-activepieces --tail 200 || true diff --git a/.github/workflows/release-self-hosted.yml b/.github/workflows/release-self-hosted.yml index 578d6ff59f4a..4277b30f0af7 100644 --- a/.github/workflows/release-self-hosted.yml +++ b/.github/workflows/release-self-hosted.yml @@ -55,6 +55,11 @@ jobs: exit 1 fi echo "Release tag base '$TAG_BASE' matches package.json version '$PACKAGE_VERSION'." + CHART_APP_VERSION="$(sed -n 's/^appVersion: "\(.*\)"/\1/p' deploy/activepieces-helm/Chart.yaml)" + if [ "$CHART_APP_VERSION" != "$PACKAGE_VERSION" ]; then + echo "::error::Helm chart appVersion '$CHART_APP_VERSION' does not match package.json version '$PACKAGE_VERSION'. Bump deploy/activepieces-helm/Chart.yaml." + exit 1 + fi - name: Fail if tag already exists run: '! docker manifest inspect activepieces/activepieces:${{ inputs.tag }}' @@ -166,8 +171,9 @@ jobs: npm pkg set version="$TAG_BASE" sed -i -E "s|(image: ghcr.io/activepieces/activepieces:)[0-9][0-9a-zA-Z.-]*|\1$TAG|" docker-compose.yml grep -q "image: ghcr.io/activepieces/activepieces:$TAG" docker-compose.yml + sed -i -E "s|^appVersion: .*|appVersion: \"$TAG_BASE\"|" deploy/activepieces-helm/Chart.yaml git checkout -b "automated/sync-version-$TAG_BASE" - git add package.json docker-compose.yml + git add package.json docker-compose.yml deploy/activepieces-helm/Chart.yaml git commit -m "chore(release): sync version to $TAG_BASE after off-cycle release" git push -f origin "automated/sync-version-$TAG_BASE" diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 13d7a3ba78c6..be0a26e5ebb4 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -52,7 +52,7 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **No egress to the CDN means no model metadata, permanently and silently.** Air-gapped installs, networks with an outbound allowlist, and CDN outages all fall back to the plain `{ id, name }` row with no message explaining it — every metadata field is optional, so nothing throws. `AP_MODEL_CATALOG_URL` points at a self-hosted mirror and is the only fix. This is a knowing exception to `.claude/rules/self-hosting.md` (decision 000032), so treat "self-hoster says prices are missing" as a network question, not a bug. - **A new provider needs an entry in the generator's `MODELS_DEV_PROVIDER` map or it silently ships with no metadata.** models.dev provider ids do not match ours: `bedrock` → `amazon-bedrock`, `qwen` → `alibaba`, `moonshot` → `moonshotai`, `activepieces` → aliased onto `openrouter` at lookup time. The six OpenAI-compatible vendors were merged before the map was updated and produced exactly this — the run prints `no upstream source: …`, which is the thing to read after adding a provider. `cloudflare-gateway` and `custom` legitimately have no source. - **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all. -- **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/setup/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists. +- **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists. - **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both. - **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run. @@ -153,9 +153,9 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **Attribution headers are for `ACTIVEPIECES` only, and go through the factory's `extraHeaders` option rather than a local `createOpenRouter` call.** The managed provider is OpenRouter under the hood on our own key, so the `x-ap-*` headers are what tag *our* account's events: `x-ap-platform-id` / `x-ap-conversation-id` / `x-ap-run-id` on the agent path, `x-ap-project-id` / `x-ap-flow-id` / `x-ap-run-id` on the piece path. BYOK `OPENROUTER` is a customer's own account and must not get them. Constructing the provider inline to attach headers is also what silently drops `openRouterSettings` (the web-search plugin), since the factory is the only place that still passes them. (`CUSTOM` separately receives the piece-path metadata headers — that is #11700's metadata forwarding for self-hosted OpenAI-compatible endpoints, older than either the rename or the Autumn work and unrelated to OpenRouter attribution. Its precedence is deliberate: admin-configured `defaultHeaders` override the `x-ap-*` metadata, and the api key is applied last.) - **`mistralViaOpenRouter` does not mean "the managed provider"; it is read only inside the `MISTRAL` case, and that branch looks like dead legacy.** `ACTIVEPIECES` routes through OpenRouter unconditionally and ignores the flag, so the only thing the agent path's `mistralViaOpenRouter: true` does is send a `MISTRAL` chat row to openrouter.ai — carrying that row's *Mistral* key, which cannot authenticate there. `MISTRAL` also has no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry, so `getCuratedChatModels` returns `undefined` for it and the resolver falls back to a tier's OpenRouter-shaped id. The fall-through arrived as a drive-by in #13489, not as a routing decision. Don't infer "this provider is AP-managed" from that case group. - **AI Tool Configs** are a *sibling* feature (same `ai/` dir), distinct from AI Providers: they give the chat assistant external capabilities via `/v1/ai-tools` (platform-admin, EE/Cloud). **AiToolCapability** = `WEB_SEARCH`/`WEB_SCRAPING`/`IMAGE_GENERATION`; **AiToolProvider** = `TAVILY`/`FIRECRAWL`/`APIFY`/`FAL`. One config per capability (unique on platformId+capability); consumed by chat via `getEnabledTools()`. **Because the config is per-platform, it can never serve a first-run flow on Cloud.** A self-serve signup lands on a brand-new platform with no configs at all, so `getEnabledTools()` returns `{}` for exactly the users a new-signup feature is aimed at, and any capability read from it silently no-ops rather than failing loudly. A capability that has to work for someone who just signed up needs a cloud-wide `AppSystemProp` key instead, the way `TURNSTILE_SECRET_KEY`, `FEATUREBASE_API_KEY` and `APPSUMO_TOKEN` are sourced. Note there is no `ENRICHMENT` capability here, so anything needing people or company enrichment has nowhere to read a key from today. -- **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the page showed the global "Failed to load data" dialog of the day. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. What made this class of bug hard to place is that the surface was then page-independent (raised from `QueryCache.onError` in `query-client.ts`) and React Query's 3 default retries meant it landed several seconds later on whatever page you navigated to next (the report was against `/platform/setup/general`). The tab now renders `DataFetchErrorState` in place instead, so a repeat would at least accuse the right page. When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. +- **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the page showed the global "Failed to load data" dialog of the day. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. What made this class of bug hard to place is that the surface was then page-independent (raised from `QueryCache.onError` in `query-client.ts`) and React Query's 3 default retries meant it landed several seconds later on whatever page you navigated to next (the report was against `/platform/general`). The tab now renders `DataFetchErrorState` in place instead, so a repeat would at least accuse the right page. When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. - **The AI Center providers page renders its whole state machine off one query, so every branch needs an `isLoading` gate above it.** `ProvidersTab` derives the empty state, the grouped list *and* the `?config=` detail view from `useAiProviderConfigs()`. While that query is pending, `data` is `undefined` — `configs` is `[]`, so the empty "Connect your first provider" screen paints for a frame, and a hard refresh on a `?config=` URL finds no matching row and falls through to the list before flipping to the detail page. The parent `SuspenseWrapper` does not cover it: the route chunk and the suspense-backed projects collection resolve first, then the component mounts with no providers yet. Any new branch added to this page belongs below the `if (isLoading) return ` gate, not beside it. -- **A save bar in the AI Center needs a full-height column, not `fixed` and not bare `sticky`.** The page sits inside *two* nested scrollers — `PlatformLayout`'s `flex flex-col flex-1 overflow-auto` and the AI Center's own `flex-1 min-w-0 overflow-auto` beside the `w-60` tab rail — so `fixed inset-x-0` centres the bar on the viewport, roughly a sidebar-width left of the content column it belongs to. Swapping it to `sticky bottom-4` fixes the centring for free but pins nothing when the content is shorter than the viewport: the bar just lands wherever the content ends, mid-page. The working shape is `min-h-full` + `flex flex-col` on the tab-content wrapper, `flex-1` on the `TabsContent`, `grow` on the panel root, then `sticky bottom-4 mt-auto` on the bar — `mt-auto` handles the short page, `sticky` handles the long one. `min-h-full` resolves only because every ancestor up to `SidebarInset h-full` has a definite height; that chain is what makes the whole thing work, so check it before reusing this elsewhere. +- **A save bar in the AI Center needs a full-height column, not `fixed` and not bare `sticky`.** The page sits inside *two* nested scrollers — `PlatformLayout`'s `flex flex-col flex-1 overflow-auto` and the AI Center's own `flex-1 overflow-auto` column — so `fixed inset-x-0` centres the bar on the viewport, roughly a sidebar-width left of the content column it belongs to. Swapping it to `sticky bottom-4` fixes the centring for free but pins nothing when the content is shorter than the viewport: the bar just lands wherever the content ends, mid-page. The working shape is `min-h-full` + `flex flex-col` on the tab-content wrapper, `flex-1` on the `TabsContent`, `grow` on the panel root, then `sticky bottom-4 mt-auto` on the bar — `mt-auto` handles the short page, `sticky` handles the long one. `min-h-full` resolves only because every ancestor up to `SidebarInset h-full` has a definite height; that chain is what makes the whole thing work, so check it before reusing this elsewhere. - **A chat *tier* is our own idea, so it has no meaning on a provider we ship no curated model list for — resolve it by asking the key what it offers.** `ACTIVEPIECES_CHAT_TIERS` (fast/smart/premium) only maps to a real id through `ALLOWED_CHAT_MODELS_BY_PROVIDER`, which covers Anthropic, OpenAI, Google, Vertex and Activepieces. Everything else — Bedrock, Azure, Mistral, xAI, DeepSeek and the other OpenAI-compatible vendors — has an empty candidate list. The resolver used to invent an id from the tier (strip the vendor prefix, dots to dashes: `anthropic/claude-haiku-4.5` -> `claude-haiku-4-5`), which Bedrock rejected as "The provided model identifier is invalid" (GIT-1840, Pylon 5836). #15355 removed the invention and left a throw in its place, which was worse: every agent turn on those providers died with `ENTITY_NOT_FOUND: this AI provider key allows no text model a chat turn can run on` before the configured model handled anything (Pylon 6069). **The missing ingredient was never a smarter guess, it was the key's own model list.** `agentHelpers.resolveModelId` is now the single resolver for every surface: it tries our catalogue first and, only when that has nothing to say, reads `aiProviderService.listModels` (process-cached, cleared nightly by cron, already filtered by the key's `modelScope`), preferring a model whose id *contains* the tier's `nativeModelId` — Bedrock spells them `eu.anthropic.claude-sonnet-4-6-...`, so a tier still selects the model it names wherever the vendor hosts it, and otherwise degrades to the first text model the way `pickAllowedModel` already does for an admin-listed catalogue. Picking from a list the provider returned is the opposite of the fabrication #15355 removed. **Two traps, both learned the hard way.** First, an agent surface and generic chat are not symmetric: an agent *carries* a real model id (its picker lists `listModels` output unfiltered, precisely because these providers have no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry) while chat carries only a tier name, because `ChatModelSelector` falls back to tier options when `getCuratedChatModels` returns `undefined`. Second, **being listed proves the key can see a model, not that the account can invoke it** — so where a model is already settled the fast round reuses it rather than choosing again (`getAgentConfig` passes the model the turn resolved; the tool RPC reads the run's own model off the conversation or the agent via `pinnedModelOf` — and it has to read the *same* half the run did: an agent-page chat runs on `agent.draft` (`agent-conversation-controller.ts`), a Run Agent step on `agent.published`, so reading the wrong one silently lends the tool a stale model whenever the two differ). Resolving the fast tier independently once picked a Haiku the account could not invoke and failed a turn whose configured Sonnet worked fine. A borrowed model still has to pass the key's current `modelScope`, or resolution falls through to the list, which applies it. Nothing crosses the worker, so a rolling deploy has no version-skew window. - `packages/server/api/src/app/ai/` — backend module: provider controller, service, entity, module, plus the sibling ai-tool-config files - `packages/server/api/src/app/ai/providers/` — per-vendor strategies keyed by `AIProviderName` diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index 54e9bd3be4a9..d28cb69017b5 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -95,7 +95,7 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, - **A row carries no payload; the input and output go to the `file` table as `MCP_CALL_PAYLOAD`.** That is what V1 got wrong — `mcp_run` had two non-null JSONB columns written synchronously per call. Keeping the fat bytes in a table the list query never reads also buys the retention for free: `MCP_CALL_PAYLOAD` is in `isExecutionDataFileThatExpires`, so it routes to S3 when configured and is swept by the hourly `FILE_CLEANUP_TRIGGER` already running. Row retention rides the same job via `mcpActivityRetention.deleteStale()`, in bounded passes modelled on `agentRetention`. - **`FileType` is declared twice and the two copies must stay in lockstep** — `core/shared/src/lib/core/file/index.ts` and `core-piece-types/src/lib/execution-contracts.ts`. Adding a member to only one breaks assignability at the seam (`sample-data.service.ts` is the first thing to fail to compile), with an error that points at the *consumer*, not at the enum you edited. -- **The Activity feed renders on two surfaces from one component, so editing its columns or filters edits a platform admin settings page too.** `ActivityFeed` (`app/routes/mcp-server/activity/`) is consumed by the project MCP page's Activity tab *and* by the third tab on `platform/setup/mcp` — a platform admin already sees the whole platform's rows there, because `GET /v1/mcp-activity` is platform-scoped and `resolveUserIdFilter` only narrows a *non*-privileged caller. The platform page is a `CenteredPage`, whose default `max-w-[40rem]` cannot hold a six-column table, so it is widened to the `max-w-[1198px]` band `PageBand` uses on the project page — which also widens its Connection and Tools tabs. The import is `app` → `app`, which the `import/no-restricted-paths` zone allows; had the feed lived in `src/features`, it could not have reached back for `useMcpNav` or `PageBand`. +- **The Activity feed renders on two surfaces from one component, so editing its columns or filters edits a platform admin settings page too.** `ActivityFeed` (`app/routes/mcp-server/activity/`) is consumed by the project MCP page's Activity tab *and* by `/platform/mcp/activity`, a sidebar child of the platform MCP page — a platform admin already sees the whole platform's rows there, because `GET /v1/mcp-activity` is platform-scoped and `resolveUserIdFilter` only narrows a *non*-privileged caller. The platform page is a `CenteredPage`, whose default `max-w-[40rem]` cannot hold a six-column table, so it is widened to the `max-w-[1198px]` band `PageBand` uses on the project page — which also widens its sibling Connection and Tools sections, since all three render through the same `CenteredPage`. The import is `app` → `app`, which the `import/no-restricted-paths` zone allows; had the feed lived in `src/features`, it could not have reached back for `useMcpNav` or `PageBand`. - **Filter options on a platform-scoped feed must be sourced by privilege, or an admin sees rows they cannot filter by.** `projectCollectionUtils.useAll()` is the member-scoped hook: it keeps TEAM projects plus only the *current user's* PERSONAL project, so on a platform-wide feed an admin gets rows from other members' personal projects with no matching entry in the Project dropdown. Use `useAllPlatformProjects()` when `useIsPlatformPrivileged()` — `/v1/projects` already scopes itself by `isUserPrivileged`, so the wider hook returns nothing extra to a member. The columns are unaffected either way: the server sends `projectName` on each row, so only the *filter* goes blind. Same shape as the platform Connections page, which sources its project filter the same way. diff --git a/brain/knowledge/data-storage-observability/index.md b/brain/knowledge/data-storage-observability/index.md index 75a895c7ef6c..a771bd9f2e78 100644 --- a/brain/knowledge/data-storage-observability/index.md +++ b/brain/knowledge/data-storage-observability/index.md @@ -40,7 +40,7 @@ All structured logging goes through **evlog** — one wide event per unit of wor ### Product Telemetry -Anonymous product analytics to PostHog, from both the browser and the app container. Gated per platform by `platform_configuration.isProductTelemetryEnabled`, edited at Platform Admin > Infrastructure > Configurations; `AP_TELEMETRY_ENABLED` survives only as the value a platform's row is *born* with. See [000033](../decisions/000033-platform-configuration-rows-are-authoritative-and-created-on-first-read.md). +Anonymous product analytics to PostHog, from both the browser and the app container. Gated per platform by `platform_configuration.isProductTelemetryEnabled`, edited at Platform Admin > Account > Configurations; `AP_TELEMETRY_ENABLED` survives only as the value a platform's row is *born* with. See [000033](../decisions/000033-platform-configuration-rows-are-authoritative-and-created-on-first-read.md). Gotchas: - **Two independent PostHog paths exist and they are gated differently.** Product analytics obeys the per-platform switch; the license-key events (`total_runs_per_day`, `ai_usage_per_run`, `chat_message`) go through `captureLicenseKeyEvent` and deliberately do **not**, because PostHog is also the billing transport and a customer must not be able to switch off their own meter. Never "consistency-fix" that by routing a billing event through the gate. diff --git a/brain/knowledge/decisions/000035-platform-admin-is-fifteen-pages-derived-from-one-registry.md b/brain/knowledge/decisions/000035-platform-admin-is-fifteen-pages-derived-from-one-registry.md index a36376c7cd0a..da5a4b3b20ed 100644 --- a/brain/knowledge/decisions/000035-platform-admin-is-fifteen-pages-derived-from-one-registry.md +++ b/brain/knowledge/decisions/000035-platform-admin-is-fifteen-pages-derived-from-one-registry.md @@ -4,6 +4,10 @@ status: accepted # Platform admin is fifteen pages derived from one registry +> **Partly superseded** by *platform-admin-sections-are-sidebar-sub-items-each-with-its-own-route* (#15727). +> The registry (`admin-pages.ts`), the overview pages and `?tab=` sections are gone. The gating-at-the-action +> and sample-mode sections below still hold. + ## Decision The platform admin nav collapses from **22 items in 5 labelled groups to 15 items in 3 unlabelled blocks**, diff --git a/brain/knowledge/decisions/000039-platform-admin-sections-are-sidebar-sub-items-each-with-its-own-route.md b/brain/knowledge/decisions/000039-platform-admin-sections-are-sidebar-sub-items-each-with-its-own-route.md new file mode 100644 index 000000000000..40f3ea465252 --- /dev/null +++ b/brain/knowledge/decisions/000039-platform-admin-sections-are-sidebar-sub-items-each-with-its-own-route.md @@ -0,0 +1,41 @@ +--- +icon: 🧭 +status: accepted +--- + +# Platform admin sections are sidebar sub-items, each with its own route + +## Decision + +The platform admin nav is six labelled groups (Platform, Catalogue, Security, Developers, Operations, +Account) written inline in `components/sidebar/platform`, and every section of a page is a sidebar sub-item +with a path of its own (`/platform/ai/capabilities`, `/platform/health/runs`), never a `?tab=`. URLs are +flat and never carry the group, so regrouping is a sidebar edit. There is no registry and there are no +overview pages. The full rule set is *platform-admin-structure*. This replaces the registry, overview and +`?tab=` parts of *platform-admin-is-fifteen-pages-derived-from-one-registry*; its gating and sample-mode +sections still hold. + +## Context + +PR #15563 merged the admin into fewer pages with tabs, derived from `routes/platform/admin-pages.ts`, and +gave six section parents an overview page. PR #15727 was moving in-page tabs into the sidebar at the same +time, and the two collided. The overviews repeated what the child pages already show, and a section that +lives on `?tab=` is invisible to the sidebar's path-based active state. + +## Why + +A path per section makes the sidebar's active state, deep links and the browser Back button work without a +tab-aware special case, and the nav stays a literal a reader can scan top to bottom. The registry was the +rejected alternative: it removed drift between nav, routes and gates, but needed `isHidden`, `hideInNav`, +`activeTabId` and `isCrowned` to describe a shape that a path per page expresses directly. + +## Consequences + +- Nav, routes and gates live in three places again (`sidebar/platform/index.tsx`, `platform-routes.tsx`, + and the page), so a new admin page has to be added to all three. +- Old `?tab=` links are rewritten to the new paths by `LegacyTabRedirect`, one `*_TAB_PATHS` map per + promoted page, and every retired `/platform/setup|security|infrastructure/*` URL by `LegacyPathRedirect`. The `?tab=` URLs #15563 introduced (`/platform/users?tab=sso`, `/platform/security?tab=…`) + were live for a day and are not redirected. +- The sample-data overlay that the registry applied per page now sits on the route, via + `routes/platform/plan-feature-sample.tsx`. A sampled page that is not wrapped there renders unlocked, + because the nine sampled pages no longer carry a `LockedFeatureGuard` of their own. diff --git a/brain/knowledge/decisions/000040-helm-chart-keys-are-generated-in-cluster-and-user-secrets-override-them.md b/brain/knowledge/decisions/000040-helm-chart-keys-are-generated-in-cluster-and-user-secrets-override-them.md new file mode 100644 index 000000000000..198fb3c2b372 --- /dev/null +++ b/brain/knowledge/decisions/000040-helm-chart-keys-are-generated-in-cluster-and-user-secrets-override-them.md @@ -0,0 +1,21 @@ +--- +status: accepted +--- + +# Helm chart keys are generated in-cluster, and user secrets override them + +## Decision + +The chart gets `AP_ENCRYPTION_KEY` and `AP_JWT_SECRET` from the mittwald `secret-generator` subchart, which fills `-secrets` and `-jwt-secret` in-cluster. The pod reads them through `envFrom`. A user-created `activepieces-auth-secrets` is mapped through `env`, so it wins at runtime. + +## Context + +GIT-1923: after worker v2 the generated secrets were never wired in, so a default install crashlooped. Users who worked around it had already created `activepieces-auth-secrets` with their own keys, and their stored data is encrypted with those keys. + +## Why + +`lookup` + `randAlphaNum` regenerates the key on every `helm template` / ArgoCD render, which would silently rotate the encryption key. Rendering one env name twice to get precedence gets rejected by Helm 4's server-side apply. `env` over `envFrom` gives the runtime fallback without either problem, and upgrades change nothing for existing users. + +## Consequences + +Renaming the generated keys or secrets orphans every install's key. A user key present at render time is pinned (`optional: false`), so the fallback only ever serves installs that never had their own key. The chart keeps a cluster-scoped operator as a dependency. diff --git a/brain/knowledge/engineering/helm-chart.md b/brain/knowledge/engineering/helm-chart.md index f707d1dcdbf8..94e6f8295a0e 100644 --- a/brain/knowledge/engineering/helm-chart.md +++ b/brain/knowledge/engineering/helm-chart.md @@ -9,16 +9,30 @@ The Kubernetes install we ship to self-hosters, at `deploy/activepieces-helm/`. ## Two paths for an AP_* variable `templates/deployment.yaml` builds one `env:` list from two values keys, in this fixed order: -- **`activepiecesConfig`** — a flat map rendered as plain `value:` entries. Rendered **first**. The shipped default holds only `AP_CONTAINER_TYPE`. -- **`activepiecesEnvVariables`** — a map of *secret name* → *list of var names*, rendered as `secretKeyRef` with `optional: true`. Rendered **second**. The shipped default routes `AP_EDITION`, `AP_EXECUTION_MODE`, `AP_ENCRYPTION_KEY`, `AP_JWT_SECRET` and the queue/auth vars through three secrets the chart does not create. +- **`activepiecesConfig`** — a flat map rendered as plain `value:` entries. Rendered **first**. Empty (`{}`) by default. +- **`activepiecesEnvVariables`** — a map of *secret name* → *list of var names*, rendered as `secretKeyRef` with `optional: true`. Rendered **second**. The shipped default routes `AP_EDITION`, `AP_EXECUTION_MODE`, `AP_ENCRYPTION_KEY`, `AP_JWT_SECRET` and the queue/auth vars through secrets the chart does not create. +- **`envFrom` the generated secrets**: lowest precedence. Supplies `AP_ENCRYPTION_KEY` / `AP_JWT_SECRET` when no user secret sets them, because `env` beats `envFrom`. ## What the chart creates -Only two secrets, both `data: {}` with mittwald `secret-generator` annotations that fill them in-cluster: `-secrets` (encryption key) and `-jwt-secret`. Postgres and Redis come from the Bitnami subcharts unless disabled. +Only two secrets, both `data: {}` with mittwald `secret-generator` annotations that fill them in-cluster: `-secrets` (`AP_ENCRYPTION_KEY`) and `-jwt-secret` (`AP_JWT_SECRET`). Postgres and Redis come from the Bitnami subcharts (off by default, images from `bitnamilegacy/*`). With `workloadType` unset, the `activepieces.workloadType` helper picks a `StatefulSet` only on a live cluster (kind-level entries like `apps/v1/StatefulSet` in `.Capabilities.APIVersions`) that has no Argo Rollouts and no `-preview` Service from an earlier Rollout revision; everything else, including offline renders, stays a `Rollout` as on main. ## Key files - `deploy/activepieces-helm` — chart, `values.yaml`, and `templates/` ## Gotchas - **Setting the same `AP_*` var in both values keys puts two entries with one name in the pod spec, and the secret wins.** `activepiecesConfig` renders before `activepiecesEnvVariables`, and for duplicate env names the later entry is what the container process sees. Since the shipped `values.yaml` already lists `AP_EDITION` and `AP_EXECUTION_MODE` under `activepieces-config-secrets`, a user who follows the docs *and* has created that secret silently gets the secret's edition, not the one they set. `optional: true` saves the common case — with no such secret the ref is skipped and the plain value survives — so this reads as "works on my cluster" right up until someone populates the secret. Set each variable in exactly one place. -- **`activepieces-config-secrets`, `activepieces-auth-secrets` and `activepieces-queue-secrets` do not exist until you make them, and the script `values.yaml` names for the job is not in the repo.** The comment points at `deploy/scripts/apply-secrets.sh --secret-name `; there is no `deploy/scripts/` directory. Every ref is `optional: true`, so a fresh `helm install` comes up anyway on the app's own defaults — which is why the gap survived: nothing fails, the vars are just quietly absent. +- **The DB and Redis secrets do not exist until you make them.** Every ref is `optional: true`, so a missing secret is silently skipped and the app falls back to its own defaults (e.g. `localhost` Postgres), even with the subcharts enabled. +- **Offline renders can't see the cluster.** Helm's default `.Capabilities.APIVersions` has group/version entries only; a connected install adds kind-level ones such as `apps/v1/StatefulSet`. That is the only live-cluster signal that works with `--create-namespace` and restricted RBAC (a `lookup` sees no namespace yet and errors on Forbidden). +- **Releases born on chart 0.3.x keep their key under legacy field names.** Their `-secrets` holds `encryption-key` (and `-jwt-secret` holds `jwt-secret`), while the generator now writes `AP_ENCRYPTION_KEY`. When no user secret pins the key, the chart looks up the legacy field and pins it, so the upgrade keeps decrypting old data. Offline renders cannot look it up; the migration guide copies it into `activepieces-auth-secrets` instead. +- **Never render an empty `AP_CONTAINER_TYPE`.** Current images treat empty and unset alike (`${AP_CONTAINER_TYPE:-WORKER_AND_APP}`), but pre-worker-v2 images start as a worker when it is set to `""`, so every API route returns 404. +- **A StatefulSet's volume template is frozen once created.** A 0.3.x user who upgrades with old values gets a StatefulSet on `local-path`; fixing `persistence.storageClassName` afterwards is rejected until the StatefulSet and its cache PVC are deleted (safe while the pod never started). +- **Chart 0.3.x only ran bundled Postgres when the release name contained `activepieces`.** It referenced `-postgresql` while Bitnami names the secret `-postgresql`. +- **A user-owned key is pinned once it exists.** If `AP_ENCRYPTION_KEY` / `AP_JWT_SECRET` are in the user's secret when the chart renders, their `secretKeyRef` becomes `optional: false`, so a deleted secret stops the pod instead of letting the generated fallback key silently take over. +- **Never render one env name twice to get precedence.** Helm 4 installs with server-side apply, which rejects duplicate `env` keys ("duplicate entries for key"). A lower-precedence default has to come through `envFrom`. +- **Removing a duplicate env name on a Helm 3 upgrade deletes both entries.** The strategic merge patch matches `env` items by name ("hides previous definition ... may be dropped when using apply"), so a var set in both values keys vanishes from the live pod spec. Never change how an already-duplicated name renders; fix duplicates in the user's values instead. +- **`AP_FRONTEND_URL` must be reachable from inside the pod.** The worker downloads piece bundles through the public URL, so a `localhost` value boots fine and then fails every flow with `fetch failed`. +- **The mittwald subchart's ClusterRole is named after the release.** Two releases with the same name in different namespaces collide, and deleting a namespace without `helm uninstall` leaves it behind. A second release can set `kubernetes-secret-generator.enabled: false`; the first operator watches every namespace. +- **Helm 4 upgrades of a `Rollout` need `--force-conflicts`.** Argo's controller owns the Service `.spec.selector` (it adds `rollouts-pod-template-hash`), so server-side apply conflicts. After a forced upgrade Argo re-adds the hash and traffic keeps routing. +- **The HPA targets whatever `activepieces.workloadType` resolves to.** It used to target a `Deployment` the chart never creates. +- **`Chart.yaml` `appVersion` is the default image tag.** `release-self-hosted.yml` fails the release if it drifts from `package.json`, and the version sync PR bumps it. - **`AP_EDITION=ee` needs `AP_EXECUTION_MODE` set in the same breath or the pod will not boot.** `system-validator.ts` throws for `cloud`/`ee` in production unless the mode is one of `SANDBOX_PROCESS`, `SANDBOX_CODE_ONLY`, `SANDBOX_CODE_AND_PROCESS`, and the default is `UNSANDBOXED`. The error names the execution mode, not the edition, so it reads as a sandboxing problem rather than the edition switch that caused it. diff --git a/brain/knowledge/engineering/index.md b/brain/knowledge/engineering/index.md index 369abbb99184..b1cc8341b10d 100644 --- a/brain/knowledge/engineering/index.md +++ b/brain/knowledge/engineering/index.md @@ -32,6 +32,7 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b - **Server Module Anatomy** — the six files of a server module (entity → migration → repo → service → controller → module), and the manual registration steps nothing auto-discovers - **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and how a failed primary query reports itself in place - **Web Navigation Shell** — the rail, the three sidebars and the layouts that choose between them, which primitives are shared, and why platform admin renders without the rail +- **Platform Admin Structure** — the rules for what is a group, page, sub-page, section, sheet or dialog in platform admin, the six groups, flat URLs, and what is applied vs planned - **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both - **Helm Chart** — the Kubernetes install we ship to self-hosters, its two competing paths for an `AP_*` variable, and the secrets it never creates - **CI Pipeline** — the PR checks that build and test a change: the affected-packages gate, the api matrix, and why install and test time grew diff --git a/brain/knowledge/engineering/platform-admin-structure.md b/brain/knowledge/engineering/platform-admin-structure.md new file mode 100644 index 000000000000..939600aeeeeb --- /dev/null +++ b/brain/knowledge/engineering/platform-admin-structure.md @@ -0,0 +1,169 @@ +--- +icon: 🗂️ +--- + +# Platform Admin Structure + +How the platform admin (`/platform/**`) is arranged: what earns a sidebar entry, what lives inside a +page, and what opens on top of one. Every rule here is marked **Applied** (true in the code today) or +**Planned** (agreed, not built yet). The project app and project settings are out of scope for now. + +## Levels + +Everything in the admin is exactly one of these. + +| Level | What it is | Own route | In the sidebar | +| --- | --- | --- | --- | +| **Group** | A labelled run of pages, named for a job an admin comes to do | no | as a label | +| **Page** | A place you come to on purpose | yes | yes | +| **Sub-page** | One of several views of the same subject | yes | under its parent | +| **Detail page** | One object big enough to own lists of its own | yes | no, reached from a row | +| **Section** | A heading partway down a page | no | no | +| **Sheet** | The detail or edit of one row | no | no | +| **Dialog** | Create, confirm or delete | no | no | + +A **filter** is not a level: the same list narrowed by scope, status or owner stays one page. + +## Deciding where something goes + +Ask in order and stop at the first yes. + +1. **Would someone come here on purpose, or do other screens link here?** A page. Otherwise it is a + section of the page they would look for it on. +2. **Is it the same subject asked a different way?** A sub-page of that subject. **Is it operated by + different people at different times?** A separate page, even if the data is related. +3. **Is it one table or a couple of toggles?** It does not earn a sidebar slot; it becomes a section + of its nearest neighbour. +4. **Is it the same list with a different scope?** A filter, not a sub-page. +5. **Is it one row's detail?** A sheet, unless it owns lists of its own, is linked from elsewhere, or + takes several steps. Then it is a detail page. +6. **Is it create, confirm or delete?** A dialog, while it stays short. A dialog that grows into a + wizard or a long form becomes a sheet or a page. A form launched from a sheet is a step inside + that sheet, never a dialog on top of it. + +Keep what you **change** apart from what you **watch**: a control you can fat-finger does not belong +on a screen people scroll through during an incident. + +## Groups — Applied + +Six groups, named for the job. The regroup renamed no page; see Naming for the planned renames. + +| Group | Pages (sub-pages in brackets) | +| --- | --- | +| **Platform** | Projects · Users (Members, Project Roles) · Connections (All, Global Connections) | +| **Catalogue** | Pieces (Pieces, Piece Sets) · Templates · AI Center (Providers, Capabilities) | +| **Security** | Single Sign On · Secret Managers · Audit Logs (Events, Event Streaming) | +| **Developers** | API Keys · Embedding · MCP Server (Connection, Tools, Activity) | +| **Operations** | Workers (Health, Worker groups) · Health (System Health, Runs Health, Queue Health) · Triggers | +| **Account** | General · Billing & subscription · Usage · Configurations (not on Cloud) | + +## Sidebar behaviour — Applied + +- **Sub-pages unfold only while you are inside their parent**, so the rail stays short. Two levels at + most: a sub-page never has children. +- **The first sub-page shares its parent's path** and carries `end: true`, otherwise it stays lit on + every sibling. +- **A crown in the sidebar means the page itself is locked**, not that something on it is paid. A page + that works with only some actions gated (Projects' New Project, the Pieces list's visibility changes) + gets no crown; the gated action carries its own. A page is locked when it cannot be used on the plan: + it shows the sample overlay, or its data is behind the plan (Piece Sets). +- **A parent wears the crown only when every sub-page under it is locked.** Otherwise the crown sits on + the locked sub-pages. A locked parent locks all its children. +- **The crown is the only mark, and a locked row stays fully clickable.** No dimming: grey reads as + "disabled", and the point of showing a paid page is that people open its preview. Hovering a crowned + row names the plan on Cloud and Enterprise ("Included in the Team plan", from `PLATFORM_FEATURES`; + Community, which cannot buy a Cloud plan, gets "Not included in your plan"), and clicking it sends + `admin.nav.locked.clicked` with the path and tier (Cloud only, so it is not in the self-hosted "Events + we track" list), so each locked entry can be judged by whether anyone opens it. A click that lands on + the page you are already on is not counted. +- **Detail pages have no sidebar entry.** You always arrive from a row. Planned: the back link always + goes somewhere concrete, never browser history. + +## URLs — Applied + +- **Flat, and the group is never in the URL.** `/platform/sso`, not `/platform/security/sso`. Moving a + page to another group is a sidebar edit and nothing else. +- **The slug matches the page's name** (`/platform/embedding`, not `/embed`), singular for a log + (`/platform/audit-log`). A sub-page nests under its parent (`/platform/users/roles`, + `/platform/audit-log/streaming`). +- **An old URL keeps working.** `LegacyPathRedirect` maps every page that shipped under + `/platform/setup/*`, `/platform/security/*` and `/platform/infrastructure/*` to its new home, keeping the + rest of the path, the query and the hash; an unknown path under one of those folders lands on the + page that folder used to open. Billing's Stripe return URL relies on the query surviving. `LegacyTabRedirect` + turns a released `?tab=` link into the matching sub-page. Of the `?tab=` URLs #15563 used for one day + before release, only the AI and Pieces ones map; the rest open the parent page. +- **A section carries only the query its pages share.** Moving between sub-pages keeps `?month=` (the + Health pages) and drops everything else, because sibling tables read the same `status`, `cursor` and + `limit` keys. A new shared key goes in `SECTION_SEARCH_KEYS` in `ap-sidebar-item-utils`. + +## Thin pages — Planned + +Rule 3 is agreed; the merges are deferred to keep #15727 small. Candidates, each still to confirm: + +- **Triggers** (one read-only table) → a section of Health. +- **Queue Health** (two cards and a table) → a section of Health › Runs. +- **Configurations** (two telemetry toggles) → a section of General. +- **MCP Server › Connection** (a URL and a collapsible) → the top of MCP Server, no sub-page. +- **API Keys** (create and revoke) → check against rule 1 before merging; admins do come here on purpose. + +## Overlays — Planned + +Rules 5 and 6 are agreed. What breaks them today: + +- **AI key detail** renders as a page through `?config=` with no route → `/platform/ai/keys/:id`. +- **Project Role editor** is a dialog with thirteen permission rows → a detail page. +- **SAML setup** is a 612-line two-step wizard inside a dialog → a sheet with steps, or a page. +- **Customize Selector** is a 601-line sheet that configures the builder → its own sub-page of Pieces. +- **Nested dialogs** (the SAML domain confirm inside the SAML dialog, the deactivate-users dialog + chained from cancel-subscription) → steps inside their parent. + +## Naming — Planned + +**Labels name the job, not the technology.** The rule is agreed; no label changes yet. + +- Single Sign On → Sign-in. Event Streaming → Forwarding. +- Triggers is called three things (sidebar "Triggers", tab "Trigger Health", header "Trigger Health + Status"); pick one. +- "Allowed domains" means embed origins on Embedding and sign-in email domains on SSO; rename one. + +## Locked features — Planned + +Five treatments exist today (sample overlay, banner with disabled actions, silent redirect, silent +hiding, and the full-page teaser). Proposed, not yet agreed: a whole locked page uses the sample overlay +(`PlanFeatureSample`); a single locked control on an otherwise usable page carries a crown and asks for +the upgrade on click, as the sidebar rule above already assumes; nothing is hidden or redirected +silently. Worker groups is the last page on the old full-page teaser. + +## Known overlaps — Planned + +- **Connections appear in three places**: the all-connections list, Global Connections, and the + Projects page's global-connections column and edit field. Global is a scope, so it should end up + a filter on one list (rule 4). +- **Worker CPU and RAM** show on both Workers › Health and Health › System. +- **Billing and Usage** read the same subscription and credits; Billing's Credits section overlaps + Usage's meters. +- **Project assignment** is done from four places: Edit Project, Edit Global Connection, a piece set's + Assigned projects, and an AI key's Project access. +- **The DNS-verify block** is duplicated between Embedding and the SAML dialog. +- **One header per page**: pages use four header styles today (`DashboardPageHeader`, `CenteredPage`, + and two hand-rolled `h1`s). Pick one and the two page widths (full for lists, narrow for forms). + +## Gotchas + +- **Adding an admin page is three edits**: the sidebar (`components/sidebar/platform`), the route + (`platform-routes.tsx`), and global search (`components/global-search/static-pages.ts`). Nothing + checks the three agree. A page whose locked state is the sample overlay also needs a + `PlanFeatureSample` entry, or it opens unlocked. +- **Moving or renaming a route needs a `LEGACY_PATHS` row**, or every bookmark, doc link and in-flight + Stripe checkout to the old URL lands on a 404. +- **Source folders still follow the old grouping** (`routes/platform/security/embed`, + `routes/platform/infra/...`). The URL is the contract; the folder is not, so do not read a page's + group from its path. + +## Key files + +- `packages/web/src/app/components/sidebar/platform` — the groups and pages, `PlatformSidebar` +- `packages/web/src/app/routes` — every admin route, in `platformRoutes` +- `packages/web/src/app/routes/platform` — the pages, `LegacyPathRedirect`, `LegacyTabRedirect`, + `PlanFeatureSample` +- `packages/web/src/app/components/sidebar` — `ApSidebarItem`, which does sub-item unfolding and the crown rule diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index 9a56c4dcb9cd..4971a24db186 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -85,7 +85,7 @@ way back was to stash the handful of intended edits, `git checkout` the package, - **Clicking through a Radix/cmdk component in a jsdom test needs the React root mounted on `document.body`, or the click never reaches React.** `createRoot(container)` attaches React's delegated listeners to `container`, but Radix `Popover` portals its content to `document.body` — a sibling of a nested root div — so synthetic events bubble body-ward, away from the listener. Items are queryable in the DOM and everything *looks* wired: the handler simply never runs, the assertion passes vacuously, and nothing tells you. `createRoot(document.body)` puts the portal inside the root container and the same dispatch fires. Three shims are needed first, each surfacing as an unrelated-looking error: `ResizeObserver` (cmdk, at mount), `Element.prototype.scrollIntoView` (cmdk, on open), and `PointerEvent` (absent in jsdom — alias it to `MouseEvent`). **And a throw inside an event handler is not a test failure.** React error boundaries only catch render/lifecycle errors, so an event-handler `TypeError` is re-thrown outside the act() call: `expect(...).toThrow()` sees nothing, the suite reports *passed*, and the only trace is vitest's `Unhandled Errors` block after the summary — which is easy to scroll past and which a `grep` for `passed|failed` hides completely. Grep the run for `Unhandled` too, and read that block as a failure. This is exactly how a crash in the multi-select property stayed invisible to a 101/101-green suite. - **A panel that hand-rolls its draft state gets none of the form validation the rest of the app assumes.** react-hook-form + `zodResolver` is what surfaces `formErrors.required` and friends; a `useState` draft with a Save button has no schema, so the usual mistake is to *substitute* a fallback for an empty field (`name.trim().length > 0 ? name.trim() : existing.name`) instead of rejecting it. That reads as a silent failure: the request succeeds, the old value returns, and nothing explains why. When a surface cannot use react-hook-form, derive the invalid state, render the message next to the field, and disable the submit — do not paper over the empty value. Bit the AI Center key-detail panel while its sibling connect dialog, on a zod resolver, was correct. The second failure mode is that such a draft never resyncs: seeded once from a prop, it outlives any refetch of the row it mirrors, so a mutation that changes the row without changing its `key` (the AI Center replaces a key's credentials, and the panel is keyed on the config id) leaves the draft describing the old row — phantom "unsaved changes", and a save that reverts what the mutation just wrote. Bump a version segment into the `key` at the site that performs the mutation rather than diffing props inside the panel: TanStack Query hands back a new object identity on every refetch, so a naive identity comparison discards the admin's unsaved edits on a window refocus. - **Sonner centres its icon against the whole toast, so a two-line toast puts the icon beside the wrong line.** `[data-sonner-toast]` is a centred flex row: fine for one line, visibly wrong the moment a description wraps or carries a disclosure — the icon drifts down next to the body instead of the title. Pass `classNames: { toast: 'items-start!', icon: 'mt-0.5' }` on that toast (the icon is 16px against 13px title text, so it needs the nudge to sit on the title's baseline). The `!` is not optional: sonner ships its own stylesheet, and a plain Tailwind `items-start` loses to it. Per-toast rather than on the `Toaster`, unless every toast in the app is meant to move. -- **To see a fetch-failure placeholder in the dev app, force the branch in code — do not try to break the network.** Patching `XMLHttpRequest.prototype.open` to rewrite the path (the api client is axios, so patching `fetch` alone does nothing) works only sometimes and costs a lot of fiddling: React Query keeps rendering the last good data, so the placeholder needs a query key with no cache; a full reload wipes the patch before the app boots, so navigation has to stay client-side; and some surfaces never error at all even when the rewritten path is confirmed to 404. Temporarily flipping the branch itself — `) : isError ? (` to `) : true || isError ? (` in `DataTable`, plus the same in each hand-written list — makes every page reachable by plain URL with no timing at all. Two cautions: it proves the *rendering* and not that `isError` is ever set, and `true || x` breaks TypeScript's narrowing after the guard, so a forced early return can throw "possibly undefined" errors into the Vite overlay — force it from the caller's prop instead when that happens. Forcing the branch is often not enough on a Community instance: agents, the AI Capabilities tab and the embed subdomain steps are behind route guards, edition checks and `LockedFeatureGuard`, so those have to be forced open too (`AgentsFlagGuard`'s redirect, `capabilitiesEnabled`, `isCloud` + `locked`) before the page renders at all. Revert with a grep for `true ||` / `false &&` / `locked={false}` before finishing. +- **To see a fetch-failure placeholder in the dev app, force the branch in code — do not try to break the network.** Patching `XMLHttpRequest.prototype.open` to rewrite the path (the api client is axios, so patching `fetch` alone does nothing) works only sometimes and costs a lot of fiddling: React Query keeps rendering the last good data, so the placeholder needs a query key with no cache; a full reload wipes the patch before the app boots, so navigation has to stay client-side; and some surfaces never error at all even when the rewritten path is confirmed to 404. Temporarily flipping the branch itself — `) : isError ? (` to `) : true || isError ? (` in `DataTable`, plus the same in each hand-written list — makes every page reachable by plain URL with no timing at all. Two cautions: it proves the *rendering* and not that `isError` is ever set, and `true || x` breaks TypeScript's narrowing after the guard, so a forced early return can throw "possibly undefined" errors into the Vite overlay — force it from the caller's prop instead when that happens. Forcing the branch is often not enough on a Community instance: agents, the AI Capabilities section and the embed subdomain steps are behind route guards, edition checks and `LockedFeatureGuard`, so those have to be forced open too (`AgentsFlagGuard`'s redirect, the `edition === ApEdition.COMMUNITY` branch in `routes/platform/setup/ai/index.tsx`, `isCloud` + `locked`) before the page renders at all. Revert with a grep for `true ||` / `false &&` / `locked={false}` before finishing. - **A table that ORs a secondary query into `isLoading` can never reach its error state.** The runs table passes `isLoading={isLoading || isFetchingFlows}`, where `isFetchingFlows` belongs to the flow list behind the *filter dropdown*. While that second query is fetching or retrying, the skeleton branch wins over `isError`, so a failing runs endpoint shows spinning rows rather than the placeholder — and a failing flows endpoint traps the table there indefinitely. Gate the skeleton on the query that owns the rows, and let a secondary query resolve on its own. @@ -99,7 +99,6 @@ way back was to stash the handful of intended edits, `git checkout` the package, - **`useWarnBeforeLosingChanges`'s `standDown` ref has to be set *after* the destructive request succeeds, not before it.** `components/custom/leave-without-saving.tsx` guards a dirty panel against navigation and `beforeunload`; `standDown` is how a deliberate exit (deleting the thing being edited) avoids prompting on its own way out. Setting it before `await`ing the delete disarms the guard for the whole request, so a refresh or tab close mid-flight discards the draft silently — and if the delete then fails, the row is still there and the edits are not. A `finally` that restores the ref does not help: the window has already passed. The ordering only works if the mutation and the navigation are separable, so the panel can stand down between them — keep the delete prop to the mutation alone and let the panel call its own `onBack`, rather than handing it one callback that does both. - **`Button`'s `keyboardShortcut` does not stop firing while the button is loading, and its listener lags a render behind.** Two separate gaps in `components/ui/button.tsx`. The element gets `disabled={disabled || loading}`, but `useKeyboardShortcut` was handed the raw `disabled` prop, so a button mid-request still ran its handler on ⌘/Ctrl+key — latent for years because every caller before the AI Center Save button was a non-loading `variant="outline"` button. Fixing that still leaves a window: the listener is registered in a passive effect, which runs after paint, so between a click and the effect re-running the old closure keeps `disabled=false`. Anything whose handler must not run twice (a mutation) needs its own guard set synchronously inside the handler — a ref, not a `disabled` prop — because no prop can close an effect-timing window. Separately, `Shortcut` renders in `text-muted-foreground`, which is invisible on a filled button; `Button` now tints it per variant, so pass nothing. - **Exported types and constants belong at the *end* of the file**, after the components and logic. Reading a file should start with what it does, not its type declarations. -- **The lock icon on a platform-admin nav item is decorative, and paid gating is done four different ways behind it.** `ApSidebarItem` renders a `LockKeyhole` when `item.locked`, but the click handler ignores `locked` entirely and navigates anyway, so the real gate is always whatever the destination page does. What that page does is not consistent: twelve pages wrap their whole body in `LockedFeatureGuard`, Pieces instead keeps the page fully usable under a `LockedAlert` banner, the branding fields in `setup/general` only set `disabled` with no explanation and no upgrade path at all, and AI Center gates on `PlatformRole.ADMIN` rather than a plan flag (`aiProvidersEnabled` gates the connect button inside it, not the page). Two of the thirteen locked nav items have no in-page hook at all. So reading the sidebar tells you a feature is paid but not what the user will actually meet, and "remove the locks" is a smaller change than it looks while "make gating consistent" is a much larger one. The active-item check is `pathname.startsWith(item.to)` with no `?tab=` awareness, so folding pages into tabs needs the `isActive` override that prop already offers and the platform sidebar never passes. - **The web has two independent "something went wrong" surfaces, and they cover different failures.** `GlobalErrorBoundary` (`app/components/global-error-boundary.tsx`) is a React error boundary: it catches *render* crashes and replaces the page with a reload/go-home fallback. It structurally cannot see a React Query failure — a failed query is stored as state, not thrown during render, unless the query opts into `throwOnError` or Suspense. Nothing global covers that async gap any more: a failed primary query is reported by the surface itself, through `DataFetchErrorState`. The two landed independently (the query surface first, in #12476 for tables; the boundary later, in #13743) and were never calibrated against each other, so for a long stretch a single 404 got a *blocking modal with raw JSON* while an actual app crash got a friendly reload button. Keep that ordering right: a failed fetch on a page that still renders is an in-place placeholder, a dead render tree is the full-page fallback. A modal is only correct when the error payload is something the user must read and copy — flow publish showing the trigger piece's stderr (`flow-hooks.tsx`) is the one case that still earns `ApErrorDialog`. - **`FriendlyErrorView` is the one renderer for a `FriendlyPieceError` — reach for it before hand-rolling a message line.** `app/builder/data-display/friendly-error-view.tsx` already turns the parsed payload into a status-keyed headline and hint (401 → "Authentication failed" + "Try reconnecting the account…"), an `HTTP {status}` badge, a labelled message block that prefers `apiMessage` over `message`, and its own `Technical Details` disclosure; it is wired to the run-details and test-step panels. For an auth failure the status-keyed *hint* is the part that actually prevents a misdiagnosis — naming whose credentials to fix beats quoting the third party's own sentence, which is what made a rejected Linear key read to a customer as their Activepieces session expiring (Pylon 5833). Three things make it non-trivial to drop into `ApErrorDialog` and all three are local fixes: it renders a second `Technical Details` next to the dialog's own, its disclosure shows `raw ?? payload` so it drops `standardOutput` (the piece's stderr, often the useful half of a failed trigger enable), and `useChangeFlowStatus` has only `flowId` so `pieceDisplayName` falls back to "What the service said". Fix those rather than growing a parallel renderer — a second one means the `message`-is-JSON trap (see *building pieces*) has to be fixed twice. - **Stubbing Radix `Select` in a jsdom test: render each `SelectItem` as a button that calls the real `onValueChange`, and never match a click by button text alone.** The component under test passes `onValueChange` to `Select`, so a stub that captures it in a `vi.hoisted` box and has `SelectItem` call it with its own `value` gives you the real state update without Radix's portal, pointer and `ResizeObserver` machinery. The trap is finding the button afterwards: the dropdown items and the UI they control often carry the same words (a Text/Image type picker next to chips whose badge also reads "Text"), and a `textContent` match takes whichever comes first in the DOM — silently clicking the dropdown item and asserting a no-op. Match on something structural (`button[title="Model Type"]` inside the chip) and give each helper a name that says which one it clicks. Also note `expect(value, 'message')` — valid vitest — is rejected by the repo's `vitest/valid-expect` lint rule, so put the identifying detail in the helper name rather than the assertion message. diff --git a/brain/knowledge/engineering/web-navigation-shell.md b/brain/knowledge/engineering/web-navigation-shell.md index b1c7f47c70c1..359c37d6fe07 100644 --- a/brain/knowledge/engineering/web-navigation-shell.md +++ b/brain/knowledge/engineering/web-navigation-shell.md @@ -18,10 +18,9 @@ of the three that understands paid gating. Its groups and items are a literal bu render, not a config. **Layouts** (`components/platform-layout.tsx`, `components/project-layout`) pick which of the above a -route gets. `PlatformLayout` is 44 lines and mounts the platform sidebar with no rail beside it. +route gets. `PlatformLayout` mounts the platform sidebar with no rail beside it. -`ap-sidebar-item` and `ap-sidebar-group` are the shared primitives, used by the dashboard and platform -sidebars only. The rail imports neither. +`ap-sidebar-item` is the item primitive, and only the platform sidebar uses it. The rail does not. ## Gotchas @@ -33,9 +32,10 @@ sidebars only. The rail imports neither. since it bundled a navigation model change together with global search and the builder canvas. Keep a navigation change scoped to one surface and it stays revertable on its own. - **The rail and the platform admin nav are aligned by hand, not by shared code.** `primary-rail` imports - none of the sidebar primitives, so the two columns only look like one system because PR #15028 tuned - their widths and spacing to match. Any change to admin nav metrics needs re-checking against the rail, - and no type error or test will tell you it drifted. + none of the sidebar item components (only `CreateProjectButton` borrows `SidebarMenuButton`), so the + two columns only look like one system because PR #15028 tuned their widths and spacing to match. Any + change to admin nav metrics needs re-checking against the rail, and no type error or test will tell you + it drifted. - **Platform admin deliberately renders without the rail.** `PlatformLayout` mounts the platform sidebar straight under `SidebarProvider`, because the rail beside it meant two left columns and a narrower admin nav, and the admin nav already carries its own way back out. This is a documented departure from the @@ -44,34 +44,37 @@ sidebars only. The rail imports neither. through `determineDefaultRoute`. - **A role check inside a platform admin page is dead code.** `PlatformLayout` calls `useIsPlatformAdmin()` and redirects away when it fails, so every component below it already knows the viewer is a platform - admin. AI Center nonetheless wrapped itself in a guard keyed on `platformRole !== PlatformRole.ADMIN`, + admin. AI Center used to wrap itself in a guard keyed on `platformRole !== PlatformRole.ADMIN`, which can never be true where it sits, and that guard was the only thing standing in for a plan gate on that page: reading it, the page looks gated when nothing gates it. Same shape as the `isCommunity` checks under `BillingPageShell`. Before adding a guard to an admin page, check whether the layout above already decided it. -- **`locked` is a platform-only concept living in a shared primitive.** `ap-sidebar-item` renders the lock - icon, but the dashboard sidebar passes `locked` zero times and the platform sidebar passes it fifteen. - So a change to lock or badge rendering edits a file two callers share to alter a path one caller uses. - Worth rendering platform-specific item chrome in the platform sidebar instead, rather than growing the - shared primitive for one consumer. +- **The crown on a nav item is a label, not a gate.** `ap-sidebar-item` draws it when `locked`, but the + click still navigates, so what the user meets is whatever the destination page does: a + `PlanFeatureSample` overlay, a banner with disabled actions, or nothing. Reading the sidebar tells you a + feature is paid, not how its page behaves. - **`/platform` redirects without `replace`, so Back lands you right back on it.** Every other admin - redirect (`/platform/setup`, `/platform/security`, `/platform/infrastructure`, `/platform/setup/branding`, - `/platform/setup/ai-capabilities`) passes `replace` to ``, but the one on `/platform` does not, - so it pushes a history entry and the browser Back button re-enters the redirect instead of leaving the - admin. Anyone who opens platform admin from a link to `/platform` rather than a specific page is stuck - going forward. Preserved deliberately while the routes were being derived from a registry, so fixing it - is a one-word change wherever that redirect is declared. + redirect passes `replace` to ``, but the one on `/platform` in `platform-routes.tsx` does not, + so the browser Back button re-enters the redirect instead of leaving the admin. A one-word fix. -- **Four platform admin pages already carry their own tabs, so they cannot become tabs of something else without nesting two deep.** `setup/pieces` (Pieces, Piece sets, plus a `piece-sets/:id` child route), `infra/workers` (Health, Worker groups), `infra/health` (System, Runs, Queue) and `setup/mcp` (Connection, Tools). Any plan that groups admin pages under a parent has to answer this for each of them, and the answer decides the item count more than the grouping does: leaving all four as their own nav items lands around seventeen, folding them in lands around thirteen but puts a second tab row inside a tab. Count `TabsTrigger` occurrences per page before promising a number, because the mockups that drive this work are drawn from the nav and cannot see the second level. +- **Admin sections are routes, not tabs.** Pieces, Workers, Health, AI Center and MCP Server used to switch + sections with in-page tabs; each section is now a sidebar sub-item with its own path, and + `LegacyTabRedirect` rewrites an old `?tab=` link to that path (MCP's tabs never had one). A new section gets a route and a + sub-item, plus an entry in the page's `*_TAB_PATHS` map if it replaces a tab. -- **The active item is a bare prefix match with no query-string awareness.** `pathname.startsWith(item.to)`, - plus an optional `isActive` override that the platform sidebar never passes. Any nav that needs to - distinguish tabs of one page has to pass that override, because `?tab=` is invisible to the default. +- **The active item is a `matchPath` on the pathname, so query strings never count.** A parent row is + active for any path under it; a sub-item that shares its parent's path needs `end: true`, or it stays lit + on every sibling. See `isRouteActive` in `ap-sidebar-item`. + +- **Tailwind v4's `data-*:` variant matches the attribute's *presence*, so a component that always renders `data-active={bool}` styles every instance as active.** React writes `data-active="false"` for a false value rather than omitting the attribute, and `data-active:bg-sidebar-accent` compiles to `[data-active]` — so `SidebarMenuSubButton`, which defaults `isActive = false` and always spreads it, painted the accent background on *every* child row at once, leaving the URL as the only signal of which one you were on. Write `data-[active=true]:` whenever the attribute is always present. The same shape sits in `sidebarMenuButtonVariants`, where it is *not* harmless: `data-active:text-sidebar-accent-foreground` paints every row at the accent foreground, so a plain `text-*` class on a row — a muted treatment for a plan-locked entry, say — loses to it (0,2,0 beats 0,1,0) and silently does nothing. Fix the variant rather than reaching for `!` on the row: an `!important` colour also beats the base's `hover:text-sidebar-accent-foreground`, so the row silently stops responding to hover. Related: that cva has no active *background* at all, so a row cannot be highlighted through `isActive` alone — the platform rail sets the background with `cn()` and an explicit `hover:bg-sidebar-accent!`, because the base's `hover:bg-sidebar-accent` is (0,2,0) and a plain `bg-*` is (0,1,0); tailwind-merge does not collapse them, since the modifiers differ. +- **The animated icons in `components/icons/*` wrap their `` in a plain `
`, so a size class on the icon sizes the wrapper and not the drawing.** The wrapper takes `className`; the svg inside carries `width`/`height` from a `size` prop (28 in 32 of the 38, 16 in five, 20 in `zap.tsx` alone) and is then overridden by whatever `[&_svg]:size-*` the parent sets — a descendant selector, which outranks any plain class on the svg. A rail asking for 20px icons therefore rendered 16px ones until the *parent* said `[&_svg]:size-5` — which wins not on specificity (it is the identical selector to the base) but because `SidebarMenuButton` composes through `cn()` and tailwind-merge drops the base for the same key. On a component that does not run its classes through `cn()`, the two rules tie and the generated stylesheet's own ordering decides — not the order you write them in the class attribute. Worse, the svg is `display: inline`, so inside that wrapper it sits on the text baseline rather than centred: two pixels high, while any bare svg beside it (`McpSvg`, the user avatar) centres correctly — which reads as a rail where the icons are subtly, inconsistently off. `[&_svg]:block` removes the baseline gap for both shapes. Measure rather than eyeball: compare each icon's `getBoundingClientRect()` centre against its row's. +- **`SidebarContent` is already a scroll container, so wrapping it in another one gives you two scrollers.** It ships `overflow-auto`, and used to carry a `no-scrollbar` class that was defined nowhere (`styles.css` defines `scrollbar-none`); the dead class has since been removed. Note that the global "6px pill" in `styles.css` is not what Chrome draws: the same block sets the standard `scrollbar-width: thin` on `*`, and from Chrome 121 a standard `scrollbar-width` makes the browser ignore every `::-webkit-scrollbar` rule, so what renders is a ~10px track that takes layout width whenever the box overflows and shows arrow buttons at both ends once `*:hover` colours it. That track is why a scrolling list's right padding reads wider than its left. Reach for `ScrollArea` wherever a thin bar matters; its `type` decides whether it shows always or on hover. The platform rail had a hand-rolled `flex-1 overflow-y-auto` around it: two nested scrollers, wheel events chaining between them, and the visible thumb belonging to the outer box. Have exactly one scroller. The platform rail makes `SidebarContent` `overflow-hidden` and scrolls through `ScrollArea` instead, whose Radix scrollbar is an overlay: it takes no layout width and has no arrow buttons, so it can sit in the rail's right padding without unbalancing it. To say there is more below, pass `showGradient` to `ScrollArea`: it veils the bottom of the list into the sidebar colour only while the viewport can still scroll down, and `gradientClassName` sets its height (the default fifth of the viewport is far too tall for a nav rail). +- **A sidebar painted the same colour as the page is judged against the content card, not its own edge.** `bg-sidebar` is also the background behind the dashboard card, so the rail's right edge is invisible and the eye measures a row's right padding to the card's border. `ProjectDashboardLayout` puts the card flush against the rail (`pr-2 pt-3 pb-3`); a layout that pads the card on the left too (`p-2`) adds that padding to the rail's apparent right gutter, and the rail reads as lopsided however symmetric its own insets measure. ## Key files - `packages/web/src/app/components/primary-rail`: the chat-first rail -- `packages/web/src/app/components/sidebar`: `ap-sidebar-item` / `ap-sidebar-group` primitives, plus the `dashboard`, `platform` and `project` navs +- `packages/web/src/app/components/sidebar`: the `ap-sidebar-item` primitive, plus the `dashboard` and `platform` navs - `packages/web/src/app/components/platform-layout.tsx`: `PlatformLayout`, the admin frame and its admin-role check - `packages/web/src/lib/route-utils.ts`: `determineDefaultRoute`, where "Back to app" goes diff --git a/brain/knowledge/pieces-engine/piece-sets.md b/brain/knowledge/pieces-engine/piece-sets.md index 2c3470e3c5b6..8add8ef26cbb 100644 --- a/brain/knowledge/pieces-engine/piece-sets.md +++ b/brain/knowledge/pieces-engine/piece-sets.md @@ -20,7 +20,7 @@ A named, reusable piece/action/trigger visibility configuration a platform admin ### Gotchas - EE/Cloud only, gated behind `platform.plan.managePiecesEnabled`. On CE / flag off, piece sets are inert and filtering falls back to legacy project-plan allow/block lists. -- The **whole** `/v1/piece-sets` module is behind that flag, `GET` included — so on a locked plan the web list query is `enabled: false`, the table is simply empty, and row actions never render. Only toolbar/entry points need a UI guard. The `LockedAlert` + `RequestTrial featureKey="ENTERPRISE_PIECES"` lives once on `PlatformPiecesPage`, above the tabs, since the same flag gates both the Pieces and Piece Sets tabs; the details route redirects back to the tab rather than hanging on a spinner waiting for a query that will never run. +- The **whole** `/v1/piece-sets` module is behind that flag, `GET` included — so on a locked plan the web list query is `enabled: false`, the table is simply empty, and row actions never render. Only toolbar/entry points need a UI guard. `PiecesLockedBanner` renders at the top of both the Pieces and the Piece Sets pages, since the same flag gates both; the details route redirects back to `/platform/pieces/piece-sets` rather than hanging on a spinner waiting for a query that will never run. - There is **no** install-time sync and no `onPieceCreated` hook — resolution is purely read-time. See ADR 0001 (visibility derived, not materialized). - Embed auth: a v4 JWT carries a `pieceSet` key claim; legacy v2/v3 tokens carry `piecesTags` (only the first tag honored, resolved to `key = tag`, else Default). Enforcement (`applyProjectPieceAccess`) runs unconditionally, not gated by the flag. - **`usePieces({ skipProjectFilter: true })` is not a caching flag — it silently turns piece-set filtering off.** It drops `projectId` from `GET /v1/pieces`, and `resolveVisibility` (`ee/pieces/filters/piece-filtering-utils.ts`) bails to `null` the moment *either* `platformId` or `projectId` is nil, so the response is the unfiltered platform catalog. `platformId` still comes from the principal, so this is not a tenancy hole — but any surface using it advertises pieces a restricted project's flows and MCP server will not actually expose. Correct for platform-admin screens (the piece-set editor has to list pieces you have not permitted yet) and for a marketing-style showcase; wrong anywhere the list implies "what you can use here". The absence of `projectId` is easy to miss at the call site because the flag reads like a client-side concern. The mirror-image trap: with the flag off, `usePieces` scopes to `authenticationSession.getProjectId()` — the *session's* project — so a platform-admin screen inspecting some other project (the MCP Reach tab, with its project picker) must pass `projectId` explicitly or it will quietly render the admin's own project's pieces under another project's name. @@ -42,7 +42,7 @@ Entry point: `pieceSetService`, defined in `piece-set.service.ts` and wired to t - `packages/server/api/src/app/ee/managed-authn/managed-authn-service.ts` — embed token enforcement via `applyProjectPieceAccess` - `packages/server/api/src/app/ee/projects/ee-project-hooks.ts` — assigns the Default set on project `postCreate` - `packages/web/src/features/piece-sets/` — client api and hooks -- `packages/web/src/app/routes/platform/setup/pieces/piece-sets/` — management UI, tabs and dialogs +- `packages/web/src/app/routes/platform/setup/pieces/piece-sets/` — management UI and dialogs - `brain/decisions/000007-piece-set-visibility-is-derived-at-read-time.md` — why visibility is derived rather than materialized Paths verified 2026-07-17. diff --git a/brain/knowledge/platform-editions-ee/embed.md b/brain/knowledge/platform-editions-ee/embed.md index 80d91a8fe3ac..d01dbe7528dc 100644 --- a/brain/knowledge/platform-editions-ee/embed.md +++ b/brain/knowledge/platform-editions-ee/embed.md @@ -50,7 +50,7 @@ That 3-minute TTL is the reason a freshly-added origin does not take effect imme ## Admin UI -The Embed Onboarding stepper at `/platform/security/embed`. Four steps exist as files — `hostname-step`, `dns-step`, `allowed-domains-step`, `signing-keys-step` — and Cloud walks all four; self-hosted skips the two Cloudflare ones because it serves the embed from `FRONTEND_URL`. +The Embed Onboarding stepper at `/platform/embedding`. Four steps exist as files — `hostname-step`, `dns-step`, `allowed-domains-step`, `signing-keys-step` — and Cloud walks all four; self-hosted skips the two Cloudflare ones because it serves the embed from `FRONTEND_URL`. ## Endpoints diff --git a/brain/knowledge/platform-editions-ee/index.md b/brain/knowledge/platform-editions-ee/index.md index 4d16cd8c2725..987cede997b6 100644 --- a/brain/knowledge/platform-editions-ee/index.md +++ b/brain/knowledge/platform-editions-ee/index.md @@ -22,7 +22,7 @@ All commercial modules live under `src/app/ee/`, registered only for EE/Cloud in Adds members, roles, git-sync releases, per-project piece sets on top of CE projects. RBAC: `rbacService.assertPrincipalAccessToProject()` branches by principal (USER→member role, ENGINE→projectId match, SERVICE→platform match). 3 default roles (ADMIN/EDITOR/VIEWER) + custom roles (`customRolesEnabled`) over 26 permissions. `ProjectRelease` (GIT_BRANCH/MANUAL/ROLLBACK) diffs then applies state atomically under a memory lock; gated by `environmentsEnabled`. Optional `workerGroupId` routes a project's flow jobs to a dedicated worker pool (`workerGroupsEnabled`). ### Embed / Signing Keys -Platform admin configures embedded workflows at `/platform/security/embed` (Cloud: 4 steps incl. Cloudflare hostname + DNS; CE/EE: 2 steps). Core is RSA-4096 signing keys (`/v1/signing-keys`, platform-admin only): private key returned exactly once, only public key stored. Vendor signs JWTs (RS256, `kid` = key id); AP verifies on `POST /v1/managed-authn/external-token`. `platform.allowedEmbedOrigins`, merged with the `AP_ALLOWED_EMBED_ORIGINS` env list, drives the CSP `frame-ancestors` header. Gated by `plan.embeddingEnabled`. +Platform admin configures embedded workflows at `/platform/embedding` (Cloud: 4 steps incl. Cloudflare hostname + DNS; CE/EE: 2 steps). Core is RSA-4096 signing keys (`/v1/signing-keys`, platform-admin only): private key returned exactly once, only public key stored. Vendor signs JWTs (RS256, `kid` = key id); AP verifies on `POST /v1/managed-authn/external-token`. `platform.allowedEmbedOrigins`, merged with the `AP_ALLOWED_EMBED_ORIGINS` env list, drives the CSP `frame-ancestors` header. Gated by `plan.embeddingEnabled`. - *Avoid:* `allowedEmbedDomains` — the old field name, gone. ### License Keys diff --git a/brain/knowledge/platform-editions-ee/platform-configuration.md b/brain/knowledge/platform-editions-ee/platform-configuration.md index 1072f75adfbf..da60246107ec 100644 --- a/brain/knowledge/platform-editions-ee/platform-configuration.md +++ b/brain/knowledge/platform-editions-ee/platform-configuration.md @@ -17,7 +17,7 @@ A **Platform** is the top-level tenant namespace in Activepieces. Every install - `GET /v1/platforms/assets/:id` — public asset download. ### Gotchas -- **The platform name is editable on every edition, even though it lives inside the Appearance section.** `appearance-section.tsx` computes `brandingLocked = !platform.plan.customAppearanceEnabled` and passes `disabled={brandingLocked}` to the logo, icon, favicon and theme-colour inputs, but **not** to the `Platform Name` input, and `formdata.append('name', name)` sits outside the `if (!brandingLocked)` block. So a Community or unlicensed platform can rename itself at Settings > Platform > Setup > General while every other field on that form is locked. Reading the file top-down makes the whole section look gated; it is per-field. `UpdatePlatformRequestBody.name` is likewise ungated on the API and only validated against `SAFE_STRING_PATTERN` (no `.` or `/`). +- **The platform name is editable on every edition, even though it lives inside the Appearance section.** `appearance-section.tsx` computes `brandingLocked = !platform.plan.customAppearanceEnabled` and passes `disabled={brandingLocked}` to the logo, icon, favicon and theme-colour inputs, but **not** to the `Platform Name` input, and `formdata.append('name', name)` sits outside the `if (!brandingLocked)` block. So a Community or unlicensed platform can rename itself at Settings > Platform > Account > General while every other field on that form is locked. Reading the file top-down makes the whole section look gated; it is per-field. `UpdatePlatformRequestBody.name` is likewise ungated on the API and only validated against `SAFE_STRING_PATTERN` (no `.` or `/`). - Per-project piece/action/trigger visibility is done via **piece sets**, NOT the platform. - On GET for USER principals, `plan.chatEnabled` is rewritten to effective per-user chat visibility, and `licenseKey` is nulled for embedded users. - Updating SAML config clears the cached SAML client (`invalidateSamlClientCache`). diff --git a/bun.lock b/bun.lock index 38acaabdf077..832ab0ebd9e2 100644 --- a/bun.lock +++ b/bun.lock @@ -1476,7 +1476,7 @@ }, "packages/pieces/community/calendly": { "name": "@activepieces/piece-calendly", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5999,7 +5999,7 @@ }, "packages/pieces/community/mistral-ai": { "name": "@activepieces/piece-mistral-ai", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7891,7 +7891,7 @@ }, "packages/pieces/community/salesforce": { "name": "@activepieces/piece-salesforce", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8235,7 +8235,7 @@ }, "packages/pieces/community/shopify": { "name": "@activepieces/piece-shopify", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9286,7 +9286,7 @@ }, "packages/pieces/community/typeform": { "name": "@activepieces/piece-typeform", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/deploy/activepieces-helm/Chart.yaml b/deploy/activepieces-helm/Chart.yaml index 9b63e2aaf935..6e3d033116a3 100644 --- a/deploy/activepieces-helm/Chart.yaml +++ b/deploy/activepieces-helm/Chart.yaml @@ -16,13 +16,13 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.3.0 +version: 0.4.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.71.2" +appVersion: "0.92.0" dependencies: - name: kubernetes-secret-generator diff --git a/deploy/activepieces-helm/templates/NOTES.txt b/deploy/activepieces-helm/templates/NOTES.txt index 93bc41757b42..190c16f0c373 100644 --- a/deploy/activepieces-helm/templates/NOTES.txt +++ b/deploy/activepieces-helm/templates/NOTES.txt @@ -1 +1,23 @@ -Activepieces has been deployed successfully! \ No newline at end of file +Activepieces has been deployed successfully! +{{- $ignored := list }} +{{- range $key := list "activepieces" "smtp" "s3" "queueUi" }} +{{- if hasKey $.Values $key }}{{ $ignored = append $ignored $key }}{{ end }} +{{- end }} +{{- range $key := list "host" "port" "useSSL" "url" "sslCa" }} +{{- if hasKey $.Values.postgresql $key }}{{ $ignored = append $ignored (printf "postgresql.%s" $key) }}{{ end }} +{{- end }} +{{- range $key := list "host" "port" "useSSL" "sslCaFile" "db" "url" "type" "user" "failedJob" }} +{{- if hasKey $.Values.redis $key }}{{ $ignored = append $ignored (printf "redis.%s" $key) }}{{ end }} +{{- end }} +{{- if dig "auth" "externalSecret" "" .Values.postgresql }}{{ $ignored = append $ignored "postgresql.auth.externalSecret" }}{{ end }} +{{- if dig "auth" "externalSecret" "" .Values.redis }}{{ $ignored = append $ignored "redis.auth.externalSecret" }}{{ end }} +{{- if hasKey .Values.persistence "storageClass" }}{{ $ignored = append $ignored "persistence.storageClass" }}{{ end }} +{{- with $ignored }} + +WARNING: this chart ignores these values from the chart 0.3.x format: +{{- range . }} + - {{ . }} +{{- end }} +Move them to the secrets listed under activepiecesEnvVariables, or persistence.storageClassName: +https://www.activepieces.com/docs/install/options/helm#migrating-from-chart-0-3-x +{{- end }} diff --git a/deploy/activepieces-helm/templates/_helpers.tpl b/deploy/activepieces-helm/templates/_helpers.tpl index 14c66b5e8436..34d574caff59 100644 --- a/deploy/activepieces-helm/templates/_helpers.tpl +++ b/deploy/activepieces-helm/templates/_helpers.tpl @@ -60,3 +60,15 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{- define "activepieces.workloadType" -}} +{{- $liveCluster := .Capabilities.APIVersions.Has "apps/v1/StatefulSet" }} +{{- $argoRollouts := .Capabilities.APIVersions.Has "argoproj.io/v1alpha1/Rollout" }} +{{- if .Values.workloadType }} +{{- .Values.workloadType }} +{{- else if and $liveCluster (not $argoRollouts) (not (lookup "v1" "Service" .Release.Namespace (printf "%s-preview" (include "activepieces.fullname" .)))) }} +{{- "statefulset" }} +{{- else }} +{{- "rollout" }} +{{- end }} +{{- end }} diff --git a/deploy/activepieces-helm/templates/deployment.yaml b/deploy/activepieces-helm/templates/deployment.yaml index e7e2c4317337..c7e29e5eb5d0 100644 --- a/deploy/activepieces-helm/templates/deployment.yaml +++ b/deploy/activepieces-helm/templates/deployment.yaml @@ -1,4 +1,4 @@ -{{- if eq (.Values.workloadType | default "rollout") "statefulset" }} +{{- if eq (include "activepieces.workloadType" .) "statefulset" }} apiVersion: apps/v1 kind: StatefulSet {{- else }} @@ -13,7 +13,7 @@ spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} - {{- if eq (.Values.workloadType | default "rollout") "statefulset" }} + {{- if eq (include "activepieces.workloadType" .) "statefulset" }} serviceName: {{ include "activepieces.fullname" . }} podManagementPolicy: {{ .Values.podManagementPolicy | default "OrderedReady" }} {{- end }} @@ -54,20 +54,35 @@ spec: - name: http containerPort: {{ .Values.container.port }} protocol: TCP + envFrom: + - secretRef: + name: {{ include "activepieces.fullname" . }}-secrets + - secretRef: + name: {{ include "activepieces.fullname" . }}-jwt-secret {{- if or .Values.activepiecesEnvVariables .Values.activepiecesConfig }} env: {{- range $key, $value := .Values.activepiecesConfig }} - name: {{ $key }} value: {{ $value | quote }} {{- end }} + {{- $fullname := include "activepieces.fullname" . }} + {{- $legacyKeys := dict "AP_ENCRYPTION_KEY" (list (printf "%s-secrets" $fullname) "encryption-key") "AP_JWT_SECRET" (list (printf "%s-jwt-secret" $fullname) "jwt-secret") }} {{- range $secretName, $vars := .Values.activepiecesEnvVariables }} {{- range $vars }} + {{- $ref := list $secretName . true }} + {{- with get $legacyKeys . }} + {{- if hasKey ((lookup "v1" "Secret" $.Release.Namespace $secretName).data | default dict) (index $ref 1) }} + {{- $ref = list $secretName (index $ref 1) false }} + {{- else if hasKey ((lookup "v1" "Secret" $.Release.Namespace (index . 0)).data | default dict) (index . 1) }} + {{- $ref = list (index . 0) (index . 1) false }} + {{- end }} + {{- end }} - name: {{ . }} valueFrom: secretKeyRef: - name: {{ $secretName | quote }} - key: {{ . }} - optional: true + name: {{ index $ref 0 | quote }} + key: {{ index $ref 1 }} + optional: {{ index $ref 2 }} {{- end }} {{- end }} {{- end }} @@ -95,7 +110,7 @@ spec: {{- end }} {{- if or .Values.persistence.enabled .Values.volumes }} volumes: - {{- if and .Values.persistence.enabled (ne (.Values.workloadType | default "rollout") "statefulset") }} + {{- if and .Values.persistence.enabled (ne (include "activepieces.workloadType" .) "statefulset") }} - name: cache persistentVolumeClaim: claimName: {{ include "activepieces.fullname" . }}-cache @@ -116,7 +131,7 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} - {{- if eq (.Values.workloadType | default "rollout") "statefulset" }} + {{- if eq (include "activepieces.workloadType" .) "statefulset" }} {{- if .Values.persistence.enabled }} volumeClaimTemplates: - metadata: diff --git a/deploy/activepieces-helm/templates/hpa.yaml b/deploy/activepieces-helm/templates/hpa.yaml index 20415a405a3d..431453f70474 100644 --- a/deploy/activepieces-helm/templates/hpa.yaml +++ b/deploy/activepieces-helm/templates/hpa.yaml @@ -7,8 +7,13 @@ metadata: {{- include "activepieces.labels" . | nindent 4 }} spec: scaleTargetRef: + {{- if eq (include "activepieces.workloadType" .) "statefulset" }} apiVersion: apps/v1 - kind: Deployment + kind: StatefulSet + {{- else }} + apiVersion: argoproj.io/v1alpha1 + kind: Rollout + {{- end }} name: {{ include "activepieces.fullname" . }} minReplicas: {{ .Values.autoscaling.minReplicas }} maxReplicas: {{ .Values.autoscaling.maxReplicas }} diff --git a/deploy/activepieces-helm/templates/pvc.yaml b/deploy/activepieces-helm/templates/pvc.yaml index f466ab5103d5..a03b5a157f38 100644 --- a/deploy/activepieces-helm/templates/pvc.yaml +++ b/deploy/activepieces-helm/templates/pvc.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.persistence.enabled (ne (.Values.workloadType | default "rollout") "statefulset") }} +{{- if and .Values.persistence.enabled (ne (include "activepieces.workloadType" .) "statefulset") }} apiVersion: v1 kind: PersistentVolumeClaim metadata: diff --git a/deploy/activepieces-helm/templates/secret.yaml b/deploy/activepieces-helm/templates/secret.yaml index 930c8d8760d7..4cfb4cb993e5 100644 --- a/deploy/activepieces-helm/templates/secret.yaml +++ b/deploy/activepieces-helm/templates/secret.yaml @@ -5,7 +5,7 @@ metadata: labels: {{- include "activepieces.labels" . | nindent 4 }} annotations: - secret-generator.v1.mittwald.de/autogenerate: encryption-key + secret-generator.v1.mittwald.de/autogenerate: AP_ENCRYPTION_KEY secret-generator.v1.mittwald.de/encoding: hex secret-generator.v1.mittwald.de/length: "32" type: Opaque @@ -18,7 +18,7 @@ metadata: labels: {{- include "activepieces.labels" . | nindent 4 }} annotations: - secret-generator.v1.mittwald.de/autogenerate: jwt-secret + secret-generator.v1.mittwald.de/autogenerate: AP_JWT_SECRET secret-generator.v1.mittwald.de/length: "64" type: Opaque data: {} \ No newline at end of file diff --git a/deploy/activepieces-helm/templates/service.yaml b/deploy/activepieces-helm/templates/service.yaml index d20a62fec795..6713b7a74b58 100644 --- a/deploy/activepieces-helm/templates/service.yaml +++ b/deploy/activepieces-helm/templates/service.yaml @@ -17,7 +17,7 @@ spec: name: api selector: {{- include "activepieces.selectorLabels" . | nindent 4 }} -{{- if ne (.Values.workloadType | default "rollout") "statefulset" }} +{{- if ne (include "activepieces.workloadType" .) "statefulset" }} --- apiVersion: v1 kind: Service diff --git a/deploy/activepieces-helm/values.yaml b/deploy/activepieces-helm/values.yaml index e4b6ff96c348..46efb2892221 100644 --- a/deploy/activepieces-helm/values.yaml +++ b/deploy/activepieces-helm/values.yaml @@ -111,6 +111,11 @@ autoscaling: targetCPUUtilizationPercentage: 80 # targetMemoryUtilizationPercentage: 80 +rollout: + blueGreen: + autoPromotionEnabled: true + scaleDownDelaySeconds: 30 + persistence: enabled: true size: 2Gi @@ -138,6 +143,8 @@ affinity: {} # ============================================================================ postgresql: enabled: false + image: + repository: bitnamilegacy/postgresql # auth: # database: activepieces # username: postgres @@ -160,6 +167,8 @@ postgresql: # ============================================================================ redis: enabled: false + image: + repository: bitnamilegacy/redis # auth: # enabled: true # password: "" # leave empty to auto-generate @@ -170,12 +179,10 @@ redis: # Deployment-specific, non-sensitive env vars set as plain values. # Use this for vars that differ per release (e.g. APP vs WORKER) and don't belong in the shared secret. -activepiecesConfig: - AP_CONTAINER_TYPE: "" +activepiecesConfig: {} # Map of Kubernetes secret name → list of AP_* (and OTEL_*) env var names to inject. # Each var name must exist as a key in the corresponding secret. -# Populate secrets with deploy/scripts/apply-secrets.sh --secret-name before deploying. activepiecesEnvVariables: activepieces-config-secrets: - AP_EDITION diff --git a/docs/admin-guide/guides/ai-capabilities.mdx b/docs/admin-guide/guides/ai-capabilities.mdx index 1412a7705b5c..8c84c3fdc3d9 100644 --- a/docs/admin-guide/guides/ai-capabilities.mdx +++ b/docs/admin-guide/guides/ai-capabilities.mdx @@ -37,7 +37,7 @@ Image Generation lets the AI assistant create images such as realistic photos, m The setup is similar across the available capabilities: -1. Go to **Platform Admin → AI Center → AI Capabilities**. +1. Go to **Platform Admin → Catalogue → AI Center → Capabilities**. 2. Find the capability you want to configure. 3. Click **Set up**. 4. Enter the API key for the external service. diff --git a/docs/admin-guide/guides/event-streaming.mdx b/docs/admin-guide/guides/event-streaming.mdx index 6e96bb0d8262..9101bcccf510 100644 --- a/docs/admin-guide/guides/event-streaming.mdx +++ b/docs/admin-guide/guides/event-streaming.mdx @@ -16,7 +16,7 @@ Use it to react to flow run failures, new sign-ins, project releases, or any oth The fastest way to get going is the **Generate handler flow** button in the New destination dialog. It builds a webhook-triggered flow with one router branch per event you select, plus a nested branch for failed runs when you pick `flow.run.finished`. -1. Open **Platform Admin → Event Streaming**. +1. Open **Platform Admin → Security → Audit Logs → Event Streaming**. 2. Click **New Destination**, pick the events you want to handle, and click **Generate handler flow**. ![Create event destination](/resources/screenshots/event-destination.png) 3. The flow lands in your personal project with a webhook trigger and a router branch per selected event. Sample data is pre-baked so you can hit Test right away, you can add a router step under any event branch to check for specific projects/flows ids. @@ -33,7 +33,7 @@ You can subscribe a destination to any audit event — flow lifecycle, run statu If you already have a webhook URL — or you want to point Event Streaming at a SIEM, data warehouse, or any other external system — you can skip the handler flow and configure the destination by hand: -1. Go to **Platform Admin → Event Streaming**. +1. Go to **Platform Admin → Security → Audit Logs → Event Streaming**. 2. Click **New Destination**. 3. Select the **Events** you want to forward. 4. Paste your **Webhook URL** (must be a valid HTTPS endpoint). diff --git a/docs/admin-guide/guides/global-connections.mdx b/docs/admin-guide/guides/global-connections.mdx index 0172489fd870..906a93c5b478 100644 --- a/docs/admin-guide/guides/global-connections.mdx +++ b/docs/admin-guide/guides/global-connections.mdx @@ -33,7 +33,7 @@ You can create a new global connection using either  **OAuth2** -**Step 1**: Go to **Platform Admin → Global Connections**. +**Step 1**: Go to **Platform Admin → Platform → Connections → Global Connections**. **Step 2**: Click **New Connection**. @@ -53,7 +53,7 @@ You can create a new global connection using either  #### **Custom OAuth2 App (Advanced) or Service Account (Advanced)** -**Step 1**: Go to **Platform Admin → Global Connections**. +**Step 1**: Go to **Platform Admin → Platform → Connections → Global Connections**. **Step 2**: Click **New Connection**. diff --git a/docs/admin-guide/guides/manage-oauth2.mdx b/docs/admin-guide/guides/manage-oauth2.mdx index 80e91f7a9c8d..7bc79ec662f8 100644 --- a/docs/admin-guide/guides/manage-oauth2.mdx +++ b/docs/admin-guide/guides/manage-oauth2.mdx @@ -18,7 +18,7 @@ When users connect to services like Google Sheets or Slack, they see "Activepiec ## How to Configure -1. Go to **Platform Admin → Setup → Pieces** +1. Go to **Platform Admin → Catalogue → Pieces** 2. Find the piece you want to configure (e.g., Google Sheets) 3. Click the lock icon to open the OAuth2 settings 4. Enter your own Client ID and Client Secret diff --git a/docs/admin-guide/guides/manage-pieces.mdx b/docs/admin-guide/guides/manage-pieces.mdx index d073ac29b783..1481a6be4536 100644 --- a/docs/admin-guide/guides/manage-pieces.mdx +++ b/docs/admin-guide/guides/manage-pieces.mdx @@ -31,7 +31,7 @@ There are **two levels** of piece management: ## Platform-Level Management -Platform administrators can manage pieces for the entire Activepieces instance from **Platform Admin → Setup → Pieces**. +Platform administrators can manage pieces for the entire Activepieces instance from **Platform Admin → Catalogue → Pieces**. ## Project-Level Management @@ -79,7 +79,7 @@ If you've built a custom piece for your organization, you can upload it directly This generates a tarball in `dist/packages/pieces/your-piece-name`. - Go to **Platform Admin → Setup → Pieces** and click **Install Piece**. + Go to **Platform Admin → Catalogue → Pieces** and click **Install Piece**. Choose **Upload File** as the installation source. diff --git a/docs/admin-guide/guides/scim/providers/microsoft-entra-id.mdx b/docs/admin-guide/guides/scim/providers/microsoft-entra-id.mdx index 32454b4fa3cc..03f1bde222d6 100644 --- a/docs/admin-guide/guides/scim/providers/microsoft-entra-id.mdx +++ b/docs/admin-guide/guides/scim/providers/microsoft-entra-id.mdx @@ -12,7 +12,7 @@ Before you start, make sure you have: - **Admin access** to your Activepieces platform - **Admin access** to your Microsoft Entra ID - SSO already configured (recommended): [SAML with Microsoft Entra ID](/admin-guide/guides/sso#saml-with-microsoft-entra-id-azure-ad) -- Generated an API key from `/platform/security/api-keys` route in the Activepieces app +- Generated an API key from `/platform/api-keys` route in the Activepieces app ## Configure SCIM Connection in Microsoft Entra ID diff --git a/docs/admin-guide/guides/scim/providers/okta.mdx b/docs/admin-guide/guides/scim/providers/okta.mdx index 98a93f26c2ce..9309720be80c 100644 --- a/docs/admin-guide/guides/scim/providers/okta.mdx +++ b/docs/admin-guide/guides/scim/providers/okta.mdx @@ -12,7 +12,7 @@ Before you start, make sure you have: - **Admin access** to your Activepieces platform - **Admin access** to your Okta tenant - SSO already configured (recommended): [SAML with Okta](/admin-guide/guides/sso#saml-with-okta) -- Generated an API key from `/platform/security/api-keys` route in the Activepieces app +- Generated an API key from `/platform/api-keys` route in the Activepieces app ## Configure SCIM Connection in Okta diff --git a/docs/admin-guide/guides/setup-ai-providers.mdx b/docs/admin-guide/guides/setup-ai-providers.mdx index f004698509cb..2a746afa06b9 100644 --- a/docs/admin-guide/guides/setup-ai-providers.mdx +++ b/docs/admin-guide/guides/setup-ai-providers.mdx @@ -21,7 +21,7 @@ This is especially useful for larger teams that want to: ### **How Connect to an AI Provider** -**Step 1**: Go to **Platform Admin → AI Center →AI Providers**. +**Step 1**: Go to **Platform Admin → Catalogue → AI Center → Providers**. **Step 2:** Find the provider you want to configure. diff --git a/docs/embedding/configure-embedding.mdx b/docs/embedding/configure-embedding.mdx index c8a37a8cd523..8ebe56415cad 100644 --- a/docs/embedding/configure-embedding.mdx +++ b/docs/embedding/configure-embedding.mdx @@ -6,7 +6,7 @@ icon: "sliders" -Before you provision users and generate signing keys, complete the embed onboarding in **Platform Settings → Security → Embedding**. +Before you provision users and generate signing keys, complete the embed onboarding in **Platform Admin → Developers → Embedding**. On **Cloud**, all four steps below are required. On **self-hosted** Enterprise, only steps 3 and 4 apply — your instance already runs on your own domain. diff --git a/docs/embedding/customize-pieces.mdx b/docs/embedding/customize-pieces.mdx index fa3d123427a4..cff7a66cb7e1 100644 --- a/docs/embedding/customize-pieces.mdx +++ b/docs/embedding/customize-pieces.mdx @@ -14,7 +14,7 @@ A piece set is a reusable, named selection of pieces (and, optionally, which of - Go to **Platform Admin -> Setup -> Pieces -> Piece Sets**, create a set, choose which pieces are visible, and give it a **Key** (e.g. `premium`). If you leave the key blank, one is generated from the name. + Go to **Platform Admin -> Catalogue -> Pieces -> Piece Sets**, create a set, choose which pieces are visible, and give it a **Key** (e.g. `premium`). If you leave the key blank, one is generated from the name. Issue a **v4** token and set the `pieceSet` claim to the set's key. Check how to generate a token in [provisioning users](./provision-users). @@ -37,7 +37,7 @@ Older integrations select pieces by tag using a **v3** token. New integrations s - Tag the pieces you would like to show to your user by going to **Platform Admin -> Setup -> Pieces**, selecting the pieces you would like to tag and hit **Apply Tags** + Tag the pieces you would like to show to your user by going to **Platform Admin -> Catalogue -> Pieces**, selecting the pieces you would like to tag and hit **Apply Tags** ![Bulk Tag](../resources/screenshots/tag-pieces.png) diff --git a/docs/embedding/predefined-connection.mdx b/docs/embedding/predefined-connection.mdx index e65760ac61ca..c5233b7b1f64 100644 --- a/docs/embedding/predefined-connection.mdx +++ b/docs/embedding/predefined-connection.mdx @@ -18,7 +18,7 @@ The high-level steps are: - Go to **Platform Admin → Security → API Keys** and create an API key. Save it for use in the next step. + Go to **Platform Admin → Developers → API Keys** and create an API key. Save it for use in the next step. ![Create API Key](/resources/screenshots/create-api-key.png) diff --git a/docs/handbook/engineering/playbooks/run-ee.mdx b/docs/handbook/engineering/playbooks/run-ee.mdx index 367d5a397bab..244cc7eb02d8 100644 --- a/docs/handbook/engineering/playbooks/run-ee.mdx +++ b/docs/handbook/engineering/playbooks/run-ee.mdx @@ -42,7 +42,7 @@ AP_JWT_SECRET=secret - After signing in, activate the license key by going to **Platform Admin -> Setup -> License Keys** + After signing in, activate the license key by going to **Platform Admin -> Account -> Billing & subscription** and clicking **Activate license key** ![Activation License Key](/resources/screenshots/activation-license-key-settings.webp) diff --git a/docs/handbook/engineering/playbooks/security-advisory-response.mdx b/docs/handbook/engineering/playbooks/security-advisory-response.mdx index 58d45af92089..4df8f6f37cbd 100644 --- a/docs/handbook/engineering/playbooks/security-advisory-response.mdx +++ b/docs/handbook/engineering/playbooks/security-advisory-response.mdx @@ -129,7 +129,7 @@ Re-confirm the disclosure date with the reporter before sending. - Confirm the new advisory appears on the platform health page (`/platform/infrastructure/health`). Source feeds cache for 15 minutes, so allow that delay before troubleshooting. + Confirm the new advisory appears on the platform health page (`/platform/health`). Source feeds cache for 15 minutes, so allow that delay before troubleshooting. Pending the security advisories panel reaching production. Until then, skip this step. diff --git a/docs/install/configure-operate/enterprise-license.mdx b/docs/install/configure-operate/enterprise-license.mdx index 35520db81789..374f3d0e2af3 100644 --- a/docs/install/configure-operate/enterprise-license.mdx +++ b/docs/install/configure-operate/enterprise-license.mdx @@ -43,7 +43,7 @@ Your key activates whichever of these your plan includes. ## How to activate a license key -Open **Platform Admin → Setup → Billing & subscription**, scroll to **Have a custom plan?**, and click **Activate license key**. Paste your key and save. No restart needed, and your flows and data stay as they are. +Open **Platform Admin → Account → Billing & subscription**, scroll to **Have a custom plan?**, and click **Activate license key**. Paste your key and save. No restart needed, and your flows and data stay as they are. Activating a license key from Billing and subscription diff --git a/docs/install/configure-operate/telemetry.mdx b/docs/install/configure-operate/telemetry.mdx index d937d47f8067..e0acbfe3e856 100644 --- a/docs/install/configure-operate/telemetry.mdx +++ b/docs/install/configure-operate/telemetry.mdx @@ -15,20 +15,20 @@ We have implemented a minimal tracking plan and provide a detailed list of the m # What Does Activepieces Collect? -We value transparency in data collection and assure you that we do not collect any personal information. The full list of events is in the app itself: go to **Platform → Infrastructure → Configurations** and open **See the events we track** under Product analytics. Alongside them we count page views inside the app. +We value transparency in data collection and assure you that we do not collect any personal information. The full list of events is in the app itself: go to **Platform Admin → Account → Configurations** and open **See the events we track** under Product analytics. Alongside them we count page views inside the app. [Exact Code](https://github.com/activepieces/activepieces/blob/main/packages/core/shared/src/lib/core/common/telemetry.ts) # Deployment setup -Licensed instances also send a daily snapshot of the *shape* of the deployment: how many app replicas and workers are running, their CPU / memory / disk sizes, their versions, worker configuration, the database / Redis / storage round-trips, the deployment settings shown on your diagnostics page (execution mode, storage location, sandbox memory limit, concurrency), and the release health check. It is the same information as **Platform → Infrastructure → Health**, and it carries no hostnames, no IP addresses, no storage endpoint, and nothing from your flows, your data or your connections. +Licensed instances also send a daily snapshot of the *shape* of the deployment: how many app replicas and workers are running, their CPU / memory / disk sizes, their versions, worker configuration, the database / Redis / storage round-trips, the deployment settings shown on your diagnostics page (execution mode, storage location, sandbox memory limit, concurrency), and the release health check. It is the same information as **Platform Admin → Operations → Health**, and it carries no hostnames, no IP addresses, no storage endpoint, and nothing from your flows, your data or your connections. We use it to answer support questions without asking you to gather diagnostics, and to see which deployment shapes a release is about to affect before we ship it. # Opting out? -Go to **Platform → Infrastructure → Configurations** and switch **Product analytics** off. The setting is stored per platform, so an instance hosting several platforms controls each one separately. +Go to **Platform Admin → Account → Configurations** and switch **Product analytics** off. The setting is stored per platform, so an instance hosting several platforms controls each one separately. **Deployment setup** is a separate switch in the same place, and is on by default. Unlike product analytics it does not read `AP_TELEMETRY_ENABLED` — switch it off in the UI to stop the daily snapshot. diff --git a/docs/install/configure-operate/worker-groups.mdx b/docs/install/configure-operate/worker-groups.mdx index ab6b8d5d1672..6bf8b79d8a2d 100644 --- a/docs/install/configure-operate/worker-groups.mdx +++ b/docs/install/configure-operate/worker-groups.mdx @@ -21,7 +21,7 @@ Use both together to guarantee capacity for busy projects and cap the rest. A worker joins a group by setting its group ID at startup. `AP_PROJECT_WORKER` selects the group's scope: -- **Project** group — leave `AP_PROJECT_WORKER=true` (the default). Assign individual projects to it from the platform's Workers → Assignments page. +- **Project** group — leave `AP_PROJECT_WORKER=true` (the default). Assign individual projects to it from **Platform Admin → Operations → Workers → Worker groups**. - **Platform** group — set `AP_PROJECT_WORKER=false`. Serves the whole platform's runs; used for dedicated/canary fleets. ```bash @@ -49,7 +49,7 @@ Once a project is assigned to a group, its flow and webhook runs are routed to t You can assign a project to a project group in two ways: -- **UI** — go to **Platform → Infrastructure → Workers** (`/platform/infrastructure/workers`), open the **Assignments** tab, and pick the group for each project. +- **UI** — go to **Platform admin → Operations → Workers → Worker groups** (`/platform/workers/groups`) and pick the group for each project. - **API** — set `workerGroupId` on the project via the projects API. Use the bare group name (the same value as the group's `AP_WORKER_GROUP_ID`); pass `null` to unassign and return the project to the shared queue. The same request also sets `maxConcurrentJobs` — the project's **concurrency limit** (the soft-cap ceiling). It's the maximum number of flow runs the project executes at once: diff --git a/docs/install/options/docker-compose.mdx b/docs/install/options/docker-compose.mdx index db1bc1770c1c..ba13b3d8127e 100755 --- a/docs/install/options/docker-compose.mdx +++ b/docs/install/options/docker-compose.mdx @@ -117,7 +117,7 @@ curl http://localhost:8080/api/v1/health All four containers should be `Up`, and the health endpoint should respond. -Then sign in and open **Platform Admin → Infrastructure → Workers**. You should see at least one worker. If the list is empty, see [Troubleshooting](#troubleshooting). +Then sign in and open **Platform Admin → Operations → Workers**. You should see at least one worker. If the list is empty, see [Troubleshooting](#troubleshooting). ## What you've just set up diff --git a/docs/install/options/helm.mdx b/docs/install/options/helm.mdx index 2e363b919282..a0102bbd55af 100644 --- a/docs/install/options/helm.mdx +++ b/docs/install/options/helm.mdx @@ -13,87 +13,37 @@ This guide walks you through deploying Activepieces on Kubernetes using the offi - Helm 3.x installed - kubectl configured to access your cluster -## Using External PostgreSQL and Redis +When `helm install` or `helm upgrade` talks to a cluster without [Argo Rollouts](https://argoproj.github.io/rollouts/), the chart deploys a `StatefulSet`; otherwise it deploys an Argo `Rollout`. A release that already runs as a `Rollout` stays one. Rendering without a cluster (`helm template`, Kustomize) produces a `Rollout`, so set `workloadType: statefulset` there, or pass the cluster's API versions as Argo CD does. The StatefulSet volume uses the `local-path` storage class unless you set `persistence.storageClassName`. -The Helm chart supports using external PostgreSQL and Redis services instead of deploying the Bitnami subcharts. +## Database and Redis -### Using External PostgreSQL +The chart reads PostgreSQL and Redis settings from the secrets listed under `activepiecesEnvVariables` in `values.yaml`. Create them before installing: -To use an external PostgreSQL instance: +Put the settings in env files, so passwords stay out of your shell history and process list. Run `umask 077` first so only you can read them: -```yaml -postgresql: - enabled: false # Disable Bitnami PostgreSQL subchart - host: "your-postgres-host.example.com" - port: 5432 - useSSL: true # Enable SSL if required - auth: - database: "activepieces" - username: "postgres" - password: "your-password" - # Or use external secret reference: - # externalSecret: - # name: "postgresql-credentials" - # key: "password" -``` - -Alternatively, you can use a connection URL: - -```yaml -postgresql: - enabled: false - url: "postgresql://user:password@host:5432/database?sslmode=require" +```bash title="db.env" +AP_POSTGRES_HOST=your-postgres-host.example.com +AP_POSTGRES_PORT=5432 +AP_POSTGRES_DATABASE=activepieces +AP_POSTGRES_USERNAME=postgres +AP_POSTGRES_PASSWORD=your-password ``` -### Using External Redis - -To use an external Redis instance: - -```yaml -redis: - enabled: false # Disable Bitnami Redis subchart - host: "your-redis-host.example.com" - port: 6379 - useSSL: false # Enable SSL if required - auth: - enabled: true - password: "your-password" - # Or use external secret reference: - # externalSecret: - # name: "redis-credentials" - # key: "password" +```bash title="redis.env" +AP_REDIS_HOST=your-redis-host.example.com +AP_REDIS_PORT=6379 +AP_REDIS_PASSWORD=your-password ``` -Alternatively, you can use a connection URL: - -```yaml -redis: - enabled: false - url: "redis://:password@host:6379/0" +```bash +kubectl create secret generic activepieces-db-secrets --from-env-file=db.env +kubectl create secret generic activepieces-redis-secrets --from-env-file=redis.env +rm db.env redis.env ``` -### External Secret References +To run PostgreSQL and Redis inside the cluster instead, set `postgresql.enabled: true` / `redis.enabled: true`. The hosts are `-postgresql` and `-redis-master`. -For better security, you can reference passwords from existing Kubernetes secrets (useful with External Secrets Operator or Sealed Secrets): - -```yaml -postgresql: - enabled: false - host: "your-postgres-host.example.com" - auth: - externalSecret: - name: "postgresql-credentials" - key: "password" - -redis: - enabled: false - host: "your-redis-host.example.com" - auth: - enabled: true - externalSecret: - name: "redis-credentials" - key: "password" -``` +`AP_JWT_SECRET` and `AP_ENCRYPTION_KEY` are generated on first install. To bring your own, put them in `activepieces-auth-secrets`; that secret takes precedence. ## Quick Start @@ -107,7 +57,9 @@ cd activepieces ### 2. Install Dependencies ```bash -helm dependency update +helm repo add mittwald https://helm.mittwald.de +helm repo add bitnami https://charts.bitnami.com/bitnami +helm dependency build deploy/activepieces-helm ``` ### 3. Create a Values File @@ -115,21 +67,28 @@ helm dependency update Create a `my-values.yaml` file with your configuration. You can use the [example values file](https://github.com/activepieces/activepieces/blob/main/deploy/activepieces-helm/values.yaml) as a reference. The Helm chart has sensible defaults for required values while leaving the optional ones empty, but you should customize these core values for production. -Include these so you can activate a [license key](/install/configure-operate/enterprise-license) later: +Set the core settings in the `activepieces-config-secrets` secret the chart already reads. `AP_EDITION` and `AP_EXECUTION_MODE` let you activate a [license key](/install/configure-operate/enterprise-license) later: + +```bash +kubectl create secret generic activepieces-config-secrets \ + --from-literal=AP_FRONTEND_URL=https://activepieces.yourdomain.com \ + --from-literal=AP_EDITION=ee \ + --from-literal=AP_EXECUTION_MODE=SANDBOX_CODE_ONLY +``` + +`AP_FRONTEND_URL` is required, and it must also be reachable from inside the pod: the worker downloads pieces through it. ```yaml -activepiecesConfig: - AP_EDITION: "ee" - AP_EXECUTION_MODE: "SANDBOX_CODE_ONLY" +persistence: + storageClassName: "your-storage-class" # see `kubectl get storageclass` ``` -Set both, and set each variable in **one place only**. `activepiecesConfig` writes plain values into the pod, while `activepiecesEnvVariables` pulls the same names from Kubernetes secrets you create yourself and renders *after* it, so a secret that holds the key wins. The chart lists `AP_EDITION` and `AP_EXECUTION_MODE` under `activepieces-config-secrets` by default, so either remove them from there or set them in that secret instead of here. +Set `AP_EDITION` and `AP_EXECUTION_MODE` together: `AP_EDITION=ee` with the default execution mode is rejected at startup and the app will not boot. -`AP_EDITION=ee` with the default execution mode is rejected at startup and the app will not boot. +Set each variable in **one place only**. A name that is both in `activepiecesConfig` and in an `activepiecesEnvVariables` list is rendered twice, which Helm 4 rejects (`duplicate entries for key`). - ### 4. Install Activepieces ```bash @@ -147,7 +106,7 @@ kubectl get services ## Production Checklist -- [ ] Set `frontendUrl` to your actual domain +- [ ] Set `AP_FRONTEND_URL` to your actual domain - [ ] Set strong passwords for PostgreSQL and Redis (or keep auto-generated) - [ ] Configure proper ingress with TLS - [ ] Set appropriate resource limits @@ -160,29 +119,97 @@ kubectl get services ```bash # Update dependencies -helm dependency update +helm dependency build deploy/activepieces-helm # Upgrade release helm upgrade activepieces deploy/activepieces-helm -f my-values.yaml -# Check upgrade status -kubectl rollout status deployment/activepieces +# Check upgrade status (StatefulSet) +kubectl rollout status statefulset/activepieces +# Check upgrade status (Argo Rollout) +kubectl wait --for=jsonpath='{.status.phase}'=Healthy rollout/activepieces --timeout=10m +``` + +With Helm 4 and `workloadType: rollout`, add `--force-conflicts` to `helm upgrade`. Argo Rollouts owns the Service selectors, and Helm 4's server-side apply stops on that conflict otherwise. + +The bundled secret generator creates cluster-wide RBAC named after the release, so give each release in a cluster its own name. A second release can also set `kubernetes-secret-generator.enabled: false`, because the first release's generator already serves every namespace. + +## Migrating from chart 0.3.x + +Chart 0.4.0 replaced the `activepieces:` block and keys such as `postgresql.host` and `redis.host` with the secrets listed under `activepiecesEnvVariables`. `helm install` and `helm upgrade` print a warning that names every old key they ignore. Run these steps before upgrading, with `RELEASE` set to your release name. + +**1. Keep your database.** Chart 0.3.x ran PostgreSQL and Redis inside the cluster by default. If you used them, keep them enabled in your values: + +```yaml +postgresql: + enabled: true + image: + repository: bitnamilegacy/postgresql +redis: + enabled: true + image: + repository: bitnamilegacy/redis +``` + +Then copy the database and Redis settings your running app uses today, so custom names, users and hosts carry over. This works the same for bundled and external databases: + +```bash +umask 077 +FULLNAME=$(kubectl get secret -l app.kubernetes.io/instance="$RELEASE" -o name | sed -n 's|^secret/\(.*\)-jwt-secret$|\1|p') +kubectl exec "deploy/$FULLNAME" -- printenv > app.env +grep '^AP_POSTGRES_' app.env | grep -v '^AP_POSTGRES_SSL_CA=' > db.env +grep '^AP_REDIS_' app.env | grep -v '^AP_REDIS_TYPE=DEFAULT$' > redis.env +kubectl create secret generic activepieces-db-secrets --from-env-file=db.env +kubectl create secret generic activepieces-redis-secrets --from-env-file=redis.env +if kubectl exec "deploy/$FULLNAME" -- printenv AP_POSTGRES_SSL_CA > ca.pem; then + kubectl patch secret activepieces-db-secrets -p "{\"data\":{\"AP_POSTGRES_SSL_CA\":\"$(base64 < ca.pem | tr -d '\n')\"}}" +fi +rm app.env db.env redis.env ca.pem +``` + +If a variable in those files is not listed under `activepiecesEnvVariables` (for example `AP_POSTGRES_URL` or `AP_REDIS_SENTINEL_HOSTS`), add it to that list in your values. + +**2. Move your settings.** Each `activepieces.` value becomes an `AP_*` variable in the `activepieces-config-secrets` secret, for example `frontendUrl` → `AP_FRONTEND_URL`, `edition` → `AP_EDITION` and `executionMode` → `AP_EXECUTION_MODE`. Set `persistence.storageClassName` to a StorageClass your cluster has (`kubectl get storageclass`); chart 0.3.x used the cluster default, and `persistence.storageClass` is no longer read. + +```bash +kubectl create secret generic activepieces-config-secrets \ + --from-literal=AP_FRONTEND_URL=https://activepieces.yourdomain.com +``` + +**3. Keep your keys.** Your encryption key and JWT secret are carried over automatically. If you deploy with `helm template`, Argo CD or Kustomize, the chart cannot see them, so copy the ones your running app uses into `activepieces-auth-secrets` first: + +```bash +umask 077 +FULLNAME=$(kubectl get secret -l app.kubernetes.io/instance="$RELEASE" -o name | sed -n 's|^secret/\(.*\)-jwt-secret$|\1|p') +kubectl exec "deploy/$FULLNAME" -- printenv | grep -E '^AP_(ENCRYPTION_KEY|JWT_SECRET)=' > auth.env +kubectl create secret generic activepieces-auth-secrets --from-env-file=auth.env +rm auth.env +``` + +**4. Upgrade.** Pin `image.tag` to the version you run today, upgrade the chart, then move to a newer image in a separate step. The app now runs as a StatefulSet (or an Argo `Rollout`) instead of a Deployment, and its piece cache volume is recreated. + +If you already upgraded with the old values and the new pod is stuck in `Pending` (for example `storageclass "local-path" not found`), Kubernetes will not let the chart change the volume. Delete the StatefulSet and its empty cache volume, then upgrade again. The pod never started, so nothing is lost: + +```bash +FULLNAME=$(kubectl get secret -l app.kubernetes.io/instance="$RELEASE" -o name | sed -n 's|^secret/\(.*\)-jwt-secret$|\1|p') +kubectl delete statefulset "$FULLNAME" +kubectl delete pvc "cache-$FULLNAME-0" ``` ## Troubleshooting ### Common Issues -1. **Pod won't start**: Check logs with `kubectl logs deployment/activepieces` +1. **Pod won't start**: Check logs with `kubectl logs -l app.kubernetes.io/instance=activepieces,app.kubernetes.io/name=activepieces` 2. **Database connection**: Verify PostgreSQL credentials and connectivity -3. **Frontend URL**: Ensure `frontendUrl` is accessible from external sources +3. **Frontend URL**: Ensure `AP_FRONTEND_URL` is accessible from external sources 4. **Webhooks not working**: Check ingress configuration and DNS resolution ### Useful Commands ```bash # View logs -kubectl logs deployment/activepieces -f +kubectl logs -l app.kubernetes.io/instance=activepieces,app.kubernetes.io/name=activepieces -f # Port forward for testing kubectl port-forward svc/activepieces 4200:80 --namespace default diff --git a/docs/install/options/railway.mdx b/docs/install/options/railway.mdx index 9e4c358c336c..e61f2a4e6043 100644 --- a/docs/install/options/railway.mdx +++ b/docs/install/options/railway.mdx @@ -47,7 +47,7 @@ See the [Sandboxing](/install/architecture/sandboxing) documentation for details #### Other Important Variables -- `AP_TELEMETRY_ENABLED`: Starting value for product analytics (default: `true`). Each platform's setting is created from it on first read; change it afterwards at Platform → Infrastructure → Configurations. +- `AP_TELEMETRY_ENABLED`: Starting value for product analytics (default: `true`). Each platform's setting is created from it on first read; change it afterwards at Platform Admin → Account → Configurations. For a complete list of all available environment variables, see the [Environment Variables](/install/reference/environment-variables) documentation. diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index 3d65b0009822..1ed3106f4217 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -65,7 +65,7 @@ protect connections and tokens, and telemetry. | ❗ `AP_JWT_SECRET` | 32-character hexadecimal key used to sign JWT tokens. Generate one with `openssl rand -hex 32`. | `None` | | `AP_CONFIG_PATH` | Path where the PGLite database and local settings are stored. | `~/.activepieces` | | `AP_INTERNAL_URL` | (Beta) Internal URL used for SSO authentication, e.g. `https://demo.activepieces.com/api`. | `None` | -| `AP_TELEMETRY_ENABLED` | Starting value for product analytics. Each platform's setting is created from it on first read; after that, change it at Platform → Infrastructure → Configurations. | `true` | +| `AP_TELEMETRY_ENABLED` | Starting value for product analytics. Each platform's setting is created from it on first read; after that, change it at Platform Admin → Account → Configurations. | `true` | `AP_FRONTEND_URL` is essential for webhooks and app triggers. It must be diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index f047f5dcd0c0..85f0c5826b7a 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.180.0", + "version": "0.181.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index de495f92ef65..de456b1cbeb3 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -33,6 +33,11 @@ type OnboardingCompleted = { platformId: string } +type AdminNavLockedClicked = { + path: string + tier?: string +} + type PlanChange = { platformId: string plan: string @@ -167,6 +172,7 @@ export enum TelemetryEventName { INVITE_SENT = 'invite.sent', INVITE_ACCEPTED = 'invite.accepted', SALES_HANDOFF_CLICKED = 'sales.handoff.clicked', + ADMIN_NAV_LOCKED_CLICKED = 'admin.nav.locked.clicked', } export type TelemetryEvent = @@ -199,6 +205,7 @@ export type TelemetryEvent = | BaseTelemetryEvent | BaseTelemetryEvent | BaseTelemetryEvent + | BaseTelemetryEvent export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new Set([ TelemetryEventName.ONBOARDING_COMPLETED, @@ -208,6 +215,7 @@ export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new TelemetryEventName.PLAN_REACTIVATED, TelemetryEventName.TRIAL_STARTED, TelemetryEventName.SALES_HANDOFF_CLICKED, + TelemetryEventName.ADMIN_NAV_LOCKED_CLICKED, TelemetryEventName.SIGNED_UP, TelemetryEventName.SIGNED_IN, TelemetryEventName.SIGN_UP_SUBMITTED, diff --git a/packages/pieces/community/calendly/.babelrc b/packages/pieces/community/calendly/.babelrc deleted file mode 100644 index fc2ad0f36c4d..000000000000 --- a/packages/pieces/community/calendly/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": [["@nx/js/babel", { "useBuiltIns": "usage" }]] -} diff --git a/packages/pieces/community/calendly/package.json b/packages/pieces/community/calendly/package.json index 19e41a6303d2..f185f038e4fd 100644 --- a/packages/pieces/community/calendly/package.json +++ b/packages/pieces/community/calendly/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-calendly", - "version": "0.5.0", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/calendly/src/index.ts b/packages/pieces/community/calendly/src/index.ts index 93a86ac0c87e..f4e3e6d70391 100644 --- a/packages/pieces/community/calendly/src/index.ts +++ b/packages/pieces/community/calendly/src/index.ts @@ -1,28 +1,74 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { calendlyCommon } from './lib/common'; import { calendlyInviteeCanceled } from './lib/trigger/invitee-canceled.trigger'; import { calendlyInviteeCreated } from './lib/trigger/invitee-created.trigger'; import { calendlyAuth } from './lib/auth'; +import { cancelScheduledEventAction } from './lib/actions/cancel-scheduled-event'; +import { createEventTypeAction } from './lib/actions/create-event-type'; +import { createOneOffEventTypeAction } from './lib/actions/create-one-off-event-type'; +import { createSchedulingLinkAction } from './lib/actions/create-scheduling-link'; +import { createShareAction } from './lib/actions/create-share'; +import { findInviteeBookingsAction } from './lib/actions/find-invitee-bookings'; +import { findUserAction } from './lib/actions/find-user'; +import { getEventInviteeAction } from './lib/actions/get-event-invitee'; +import { getEventTypeAvailabilityAction } from './lib/actions/get-event-type-availability'; +import { getEventTypeAction } from './lib/actions/get-event-type'; +import { getOrganizationMembershipAction } from './lib/actions/get-organization-membership'; +import { getOrganizationAction } from './lib/actions/get-organization'; +import { getScheduledEventAction } from './lib/actions/get-scheduled-event'; +import { getUserAvailabilityScheduleAction } from './lib/actions/get-user-availability-schedule'; +import { getUserAction } from './lib/actions/get-user'; +import { listAvailableTimesAction } from './lib/actions/list-available-times'; +import { listEventInviteesAction } from './lib/actions/list-event-invitees'; +import { listEventTypeHostsAction } from './lib/actions/list-event-type-hosts'; +import { listEventTypesAction } from './lib/actions/list-event-types'; +import { listOrganizationMembershipsAction } from './lib/actions/list-organization-memberships'; +import { listScheduledEventsAction } from './lib/actions/list-scheduled-events'; +import { listUserAvailabilitySchedulesAction } from './lib/actions/list-user-availability-schedules'; +import { listUserBusyTimesAction } from './lib/actions/list-user-busy-times'; +import { listUserLocationsAction } from './lib/actions/list-user-locations'; +import { updateEventTypeAvailabilityAction } from './lib/actions/update-event-type-availability'; +import { updateEventTypeAction } from './lib/actions/update-event-type'; -const markdown = ` -## Obtain your Calendly Personal Token -1. Go to https://calendly.com/integrations/api_webhooks -2. Click on "Create New Token" -3. Copy the token and paste it in the field below -`; export const calendly = createPiece({ displayName: 'Calendly', description: 'Simple, modern scheduling', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/calendly.png', categories: [PieceCategory.PRODUCTIVITY], authors: ["kishanprmr","MoShizzle","AbdulTheActivePiecer","khaledmashaly","abuaboud"], auth: calendlyAuth, actions: [ + cancelScheduledEventAction, + createEventTypeAction, + createOneOffEventTypeAction, + createSchedulingLinkAction, + createShareAction, + findInviteeBookingsAction, + findUserAction, + getEventInviteeAction, + getEventTypeAvailabilityAction, + getEventTypeAction, + getOrganizationMembershipAction, + getOrganizationAction, + getScheduledEventAction, + getUserAvailabilityScheduleAction, + getUserAction, + listAvailableTimesAction, + listEventInviteesAction, + listEventTypeHostsAction, + listEventTypesAction, + listOrganizationMembershipsAction, + listScheduledEventsAction, + listUserAvailabilitySchedulesAction, + listUserBusyTimesAction, + listUserLocationsAction, + updateEventTypeAvailabilityAction, + updateEventTypeAction, createCustomApiCallAction({ - baseUrl: () => calendlyCommon.baseUrl, // Replace with the actual base URL + baseUrl: () => calendlyCommon.baseUrl, auth: calendlyAuth, authMapping: async (auth) => ({ Authorization: `Bearer ${auth.secret_text}`, diff --git a/packages/pieces/community/calendly/src/lib/actions/cancel-scheduled-event.ts b/packages/pieces/community/calendly/src/lib/actions/cancel-scheduled-event.ts new file mode 100644 index 000000000000..fb5af48954fe --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/cancel-scheduled-event.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { cancellationOutputSchema } from '../output-schemas'; + +export const cancelScheduledEventAction = createAction({ + auth: calendlyAuth, + name: 'cancel_scheduled_event', + classification: 'DESTRUCTIVE', + displayName: 'Cancel Event', + description: 'Cancels a booked meeting and notifies the invitees.', + audience: 'both', + aiMetadata: { + description: + 'Cancel one upcoming booked Calendly meeting by URI or UUID; Calendly emails the invitees with the optional Reason. Past meetings, already canceled meetings and meetings the connected user does not host cannot be canceled. Cannot be undone; confirm with the user first.', + idempotent: false, + }, + outputSchema: cancellationOutputSchema, + props: { + scheduledEvent: calendlyCommon.scheduledEvent, + reason: Property.LongText({ + displayName: 'Reason', + description: 'Shown to the invitees. Up to 10,000 characters.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.POST, + path: `/scheduled_events/${calendlyCommon.toUuid({ value: propsValue.scheduledEvent })}/cancellation`, + body: calendlyCommon.isProvided(propsValue.reason) ? { reason: propsValue.reason } : {}, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/create-event-type.ts b/packages/pieces/community/calendly/src/lib/actions/create-event-type.ts new file mode 100644 index 000000000000..a248062c3f7d --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/create-event-type.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { eventTypeOutputSchema } from '../output-schemas'; + +export const createEventTypeAction = createAction({ + auth: calendlyAuth, + name: 'create_event_type', + classification: 'WRITE', + displayName: 'Create Event Type', + description: 'Creates a one-on-one event type.', + audience: 'ai', + aiMetadata: { + description: + 'Create a new one-on-one (solo) Calendly event type owned by a user (defaults to the connected user). Only one-on-one event types can be created through the API. Returns the new event type with its URI and scheduling URL. Not idempotent: each call creates another event type.', + idempotent: false, + }, + outputSchema: eventTypeOutputSchema, + props: { + owner: Property.ShortText({ + displayName: 'Owner', + description: 'User URI or UUID of the host. Leave empty for the connected user.', + required: false, + }), + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + ...calendlyCommon.eventTypeFields, + }, + async run({ auth, propsValue }) { + const owner = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.owner }); + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.POST, + path: '/event_types', + body: { + owner, + name: propsValue.name, + ...calendlyCommon.eventTypeBody(propsValue), + }, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/create-one-off-event-type.ts b/packages/pieces/community/calendly/src/lib/actions/create-one-off-event-type.ts new file mode 100644 index 000000000000..ba1e1c44fc5b --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/create-one-off-event-type.ts @@ -0,0 +1,104 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { eventTypeOutputSchema } from '../output-schemas'; + +export const createOneOffEventTypeAction = createAction({ + auth: calendlyAuth, + name: 'create_one_off_event_type', + classification: 'WRITE', + displayName: 'Create One-Off Meeting Link', + description: 'Creates a one-off meeting and returns its booking link.', + audience: 'both', + aiMetadata: { + description: + "Create a Calendly one-off meeting: a temporary event type bookable only between Start Date and End Date, outside the host's regular event types. Returns the one-off event type with its scheduling_url to send to the invitee. Not idempotent: each call creates another one-off meeting.", + idempotent: false, + }, + outputSchema: eventTypeOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + duration: Property.Number({ + displayName: 'Duration (minutes)', + required: true, + }), + startDate: Property.ShortText({ + displayName: 'Start Date', + description: 'First bookable date, YYYY-MM-DD.', + required: true, + }), + endDate: Property.ShortText({ + displayName: 'End Date', + description: 'Last bookable date, YYYY-MM-DD.', + required: true, + }), + timezone: Property.ShortText({ + displayName: 'Timezone', + description: "IANA timezone such as America/New_York. Defaults to the host's timezone.", + required: false, + }), + host: Property.ShortText({ + displayName: 'Host', + description: 'User URI or UUID of the host. Leave empty for the connected user.', + required: false, + }), + locationKind: Property.StaticDropdown({ + displayName: 'Location', + required: false, + options: { + options: [ + { label: 'Google Meet', value: 'google_conference' }, + { label: 'Zoom', value: 'zoom_conference' }, + { label: 'Microsoft Teams', value: 'microsoft_teams_conference' }, + { label: 'Webex', value: 'webex_conference' }, + { label: 'GoToMeeting', value: 'gotomeeting_conference' }, + { label: 'In person', value: 'physical' }, + { label: 'Phone call (invitee calls host)', value: 'inbound_call' }, + { label: 'Phone call (host calls invitee)', value: 'outbound_call' }, + { label: 'Custom', value: 'custom' }, + { label: 'Ask invitee', value: 'ask_invitee' }, + ], + }, + }), + location: Property.ShortText({ + displayName: 'Location Details', + description: 'Address for in person, text for custom, or the phone number for inbound calls.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const host = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.host }); + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.POST, + path: '/one_off_event_types', + body: { + name: propsValue.name, + host, + duration: propsValue.duration, + ...(calendlyCommon.isProvided(propsValue.timezone) ? { timezone: propsValue.timezone.trim() } : {}), + date_setting: { + type: 'date_range', + start_date: propsValue.startDate.trim(), + end_date: propsValue.endDate.trim(), + }, + ...(calendlyCommon.isProvided(propsValue.locationKind) + ? { location: toLocation({ kind: propsValue.locationKind, details: propsValue.location }) } + : {}), + }, + }); + return response.resource; + }, +}); + +function toLocation({ kind, details }: { kind: string; details: string | undefined }): Record { + const value = details?.trim(); + if (!value) { + return { kind }; + } + return kind === 'inbound_call' ? { kind, phone_number: value } : { kind, location: value }; +} diff --git a/packages/pieces/community/calendly/src/lib/actions/create-scheduling-link.ts b/packages/pieces/community/calendly/src/lib/actions/create-scheduling-link.ts new file mode 100644 index 000000000000..fd3ccdfff425 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/create-scheduling-link.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { schedulingLinkOutputSchema } from '../output-schemas'; + +export const createSchedulingLinkAction = createAction({ + auth: calendlyAuth, + name: 'create_scheduling_link', + classification: 'WRITE', + displayName: 'Create Single-Use Scheduling Link', + description: 'Creates a booking link for an event type that works for one booking.', + audience: 'ai', + aiMetadata: { + description: + 'Create a single-use Calendly booking link for an active event type: the booking_url expires after one meeting is booked. Use it to send one person a private link; use the event type scheduling_url for a reusable link. Not idempotent: each call creates a new link.', + idempotent: false, + }, + outputSchema: schedulingLinkOutputSchema, + props: { + eventType: calendlyCommon.eventType, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.POST, + path: '/scheduling_links', + body: { + max_event_count: 1, + owner: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + owner_type: 'EventType', + }, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/create-share.ts b/packages/pieces/community/calendly/src/lib/actions/create-share.ts new file mode 100644 index 000000000000..097cdb09429a --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/create-share.ts @@ -0,0 +1,70 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { shareOutputSchema } from '../output-schemas'; + +export const createShareAction = createAction({ + auth: calendlyAuth, + name: 'create_share', + classification: 'WRITE', + displayName: 'Create Customized Share Link', + description: 'Creates a booking link that overrides some settings of a one-on-one event type.', + audience: 'ai', + aiMetadata: { + description: + 'Create a customized one-time share of a one-on-one (solo) Calendly event type, overriding its name, duration or booking window without changing the event type itself. Returns scheduling_links with the booking_url to send. Only one-on-one event types can be shared. Not idempotent: each call creates a new share.', + idempotent: false, + }, + outputSchema: shareOutputSchema, + props: { + eventType: calendlyCommon.eventType, + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + duration: Property.Number({ + displayName: 'Duration (minutes)', + required: false, + }), + startDate: Property.ShortText({ + displayName: 'Start Date', + description: 'First bookable date, YYYY-MM-DD. Set together with End Date to limit the booking window.', + required: false, + }), + endDate: Property.ShortText({ + displayName: 'End Date', + description: 'Last bookable date, YYYY-MM-DD.', + required: false, + }), + hideLocation: Property.Checkbox({ + displayName: 'Hide Location Until Booked', + required: false, + }), + }, + async run({ auth, propsValue }) { + const hasStartDate = calendlyCommon.isProvided(propsValue.startDate); + const hasEndDate = calendlyCommon.isProvided(propsValue.endDate); + if (hasStartDate !== hasEndDate) { + throw new Error('Set both Start Date and End Date to limit the booking window, or leave both empty.'); + } + const hasWindow = hasStartDate && hasEndDate; + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.POST, + path: '/shares', + body: { + event_type: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + ...(calendlyCommon.isProvided(propsValue.name) ? { name: propsValue.name } : {}), + ...(propsValue.duration !== undefined && propsValue.duration !== null + ? { duration: propsValue.duration } + : {}), + ...(hasWindow + ? { period_type: 'fixed', start_date: propsValue.startDate?.trim(), end_date: propsValue.endDate?.trim() } + : {}), + ...(propsValue.hideLocation ? { hide_location: true } : {}), + }, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/find-invitee-bookings.ts b/packages/pieces/community/calendly/src/lib/actions/find-invitee-bookings.ts new file mode 100644 index 000000000000..74b2ea4233dd --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/find-invitee-bookings.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { findInviteeBookingsOutputSchema } from '../output-schemas'; + +export const findInviteeBookingsAction = createAction({ + auth: calendlyAuth, + name: 'find_invitee_bookings', + classification: 'SEARCH', + displayName: 'Find Invitee by Email', + description: "Finds the meetings an invitee booked with you, by the invitee's email.", + audience: 'both', + aiMetadata: { + description: + "Find the Calendly meetings a person booked with the connected user, by the invitee's email: up to the 100 most recent, newest first. Returns found=false when there are none. Read-only.", + idempotent: true, + }, + outputSchema: findInviteeBookingsOutputSchema, + props: { + email: Property.ShortText({ + displayName: 'Invitee Email', + required: true, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Active', value: 'active' }, + { label: 'Canceled', value: 'canceled' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + const user = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: undefined }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/scheduled_events', + queryParams: { + user, + invitee_email: propsValue.email.trim(), + status: propsValue.status, + sort: 'start_time:desc', + count: '100', + }, + }); + return { + found: response.collection.length > 0, + count: response.collection.length, + events: response.collection, + }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/find-user.ts b/packages/pieces/community/calendly/src/lib/actions/find-user.ts new file mode 100644 index 000000000000..f8d66775b5c7 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/find-user.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { findUserOutputSchema } from '../output-schemas'; + +export const findUserAction = createAction({ + auth: calendlyAuth, + name: 'find_user', + classification: 'SEARCH', + displayName: 'Find User', + description: "Finds a user in your Calendly organization by email.", + audience: 'both', + aiMetadata: { + description: + "Find a member of the connected user's Calendly organization by exact email. Returns found=false when nobody matches, otherwise the user (URI, name, email, scheduling URL, timezone) and their role. Read-only.", + idempotent: true, + }, + outputSchema: findUserOutputSchema, + props: { + email: Property.ShortText({ + displayName: 'Email', + required: true, + }), + }, + async run({ auth, propsValue }) { + const organization = await calendlyCommon.resolveOrganizationUri({ + token: auth.secret_text, + organization: undefined, + }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/organization_memberships', + queryParams: { organization, email: propsValue.email.trim(), count: '1' }, + }); + const membership = response.collection[0]; + if (!membership) { + return { found: false, user: null, role: null, membership_uri: null }; + } + return { + found: true, + user: membership.user, + role: membership.role, + membership_uri: membership.uri, + }; + }, +}); + +type MembershipRecord = { + uri: string; + role: string; + user: CalendlyRecord; +}; diff --git a/packages/pieces/community/calendly/src/lib/actions/get-event-invitee.ts b/packages/pieces/community/calendly/src/lib/actions/get-event-invitee.ts new file mode 100644 index 000000000000..08112d660457 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-event-invitee.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { eventInviteeOutputSchema } from '../output-schemas'; + +export const getEventInviteeAction = createAction({ + auth: calendlyAuth, + name: 'get_event_invitee', + classification: 'READ', + displayName: 'Get Event Invitee', + description: 'Gets one invitee of a booked meeting.', + audience: 'ai', + aiMetadata: { + description: + 'Get one invitee of a booked Calendly meeting by the invitee URI (or its UUID plus the scheduled event): name, email, status, timezone, booking question answers, UTM tracking, payment, no-show record and cancel/reschedule URLs. Read-only.', + idempotent: true, + }, + outputSchema: eventInviteeOutputSchema, + props: { + invitee: calendlyCommon.invitee, + scheduledEvent: calendlyCommon.optionalScheduledEvent, + }, + async run({ auth, propsValue }) { + const inviteeUri = calendlyCommon.toInviteeUri({ + invitee: propsValue.invitee, + scheduledEvent: propsValue.scheduledEvent, + }); + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: new URL(inviteeUri).pathname, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-event-type-availability.ts b/packages/pieces/community/calendly/src/lib/actions/get-event-type-availability.ts new file mode 100644 index 000000000000..35d7ec3775fb --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-event-type-availability.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { eventTypeAvailabilityOutputSchema } from '../output-schemas'; + +export const getEventTypeAvailabilityAction = createAction({ + auth: calendlyAuth, + name: 'get_event_type_availability', + classification: 'READ', + displayName: 'Get Event Type Availability', + description: 'Gets the availability schedule of an event type.', + audience: 'ai', + aiMetadata: { + description: + 'Get the weekly availability schedule of one Calendly event type: timezone and rules (per weekday or per date, each with from/to intervals). Call this before Update Event Type Availability, because an update replaces every rule. Read-only.', + idempotent: true, + }, + outputSchema: eventTypeAvailabilityOutputSchema, + props: { + eventType: calendlyCommon.eventType, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/event_type_availability_schedules', + queryParams: { + event_type: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + }, + }); + return { items: response.collection, count: response.collection.length }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-event-type.ts b/packages/pieces/community/calendly/src/lib/actions/get-event-type.ts new file mode 100644 index 000000000000..279b7eb2bf84 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-event-type.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { eventTypeOutputSchema } from '../output-schemas'; + +export const getEventTypeAction = createAction({ + auth: calendlyAuth, + name: 'get_event_type', + classification: 'READ', + displayName: 'Get Event Type', + description: 'Gets one event type.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Calendly event type by URI or UUID: name, duration and duration options, kind, active flag, locations, custom questions, profile and scheduling URL. Read-only.', + idempotent: true, + }, + outputSchema: eventTypeOutputSchema, + props: { + eventType: calendlyCommon.eventType, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/event_types/${calendlyCommon.toUuid({ value: propsValue.eventType })}`, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-organization-membership.ts b/packages/pieces/community/calendly/src/lib/actions/get-organization-membership.ts new file mode 100644 index 000000000000..e30a19329a19 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-organization-membership.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { organizationMembershipOutputSchema } from '../output-schemas'; + +export const getOrganizationMembershipAction = createAction({ + auth: calendlyAuth, + name: 'get_organization_membership', + classification: 'READ', + displayName: 'Get Organization Membership', + description: 'Gets one organization membership.', + audience: 'ai', + aiMetadata: { + description: + "Get one Calendly organization membership by its URI or UUID (from List Organization Members): the member's role and user profile. Read-only.", + idempotent: true, + }, + outputSchema: organizationMembershipOutputSchema, + props: { + membership: Property.ShortText({ + displayName: 'Membership', + description: 'Organization membership URI or UUID.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/organization_memberships/${calendlyCommon.toUuid({ value: propsValue.membership })}`, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-organization.ts b/packages/pieces/community/calendly/src/lib/actions/get-organization.ts new file mode 100644 index 000000000000..b2f11a9fd3d9 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-organization.ts @@ -0,0 +1,35 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { organizationOutputSchema } from '../output-schemas'; + +export const getOrganizationAction = createAction({ + auth: calendlyAuth, + name: 'get_organization', + classification: 'READ', + displayName: 'Get Organization', + description: 'Gets a Calendly organization, including its plan.', + audience: 'ai', + aiMetadata: { + description: + "Get a Calendly organization's name, plan (basic, standard, teams, enterprise...) and stage (free, trial, paid). Leave Organization empty for the connected user's organization. Use it to check whether plan-gated features are available. Read-only.", + idempotent: true, + }, + outputSchema: organizationOutputSchema, + props: { + organization: calendlyCommon.organization, + }, + async run({ auth, propsValue }) { + const organization = await calendlyCommon.resolveOrganizationUri({ + token: auth.secret_text, + organization: propsValue.organization, + }); + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/organizations/${calendlyCommon.toUuid({ value: organization })}`, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-scheduled-event.ts b/packages/pieces/community/calendly/src/lib/actions/get-scheduled-event.ts new file mode 100644 index 000000000000..30a8c3ba09e5 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-scheduled-event.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { scheduledEventOutputSchema } from '../output-schemas'; + +export const getScheduledEventAction = createAction({ + auth: calendlyAuth, + name: 'get_scheduled_event', + classification: 'READ', + displayName: 'Find Event', + description: 'Gets a booked meeting by its URI or UUID.', + audience: 'both', + aiMetadata: { + description: + 'Get one booked Calendly meeting by URI or UUID: name, status, start and end time, event type, location (join URL for video calls), hosts, invitee counts and cancellation details when canceled. Invitees are not included; use List Event Invitees. Read-only.', + idempotent: true, + }, + outputSchema: scheduledEventOutputSchema, + props: { + scheduledEvent: calendlyCommon.scheduledEvent, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/scheduled_events/${calendlyCommon.toUuid({ value: propsValue.scheduledEvent })}`, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-user-availability-schedule.ts b/packages/pieces/community/calendly/src/lib/actions/get-user-availability-schedule.ts new file mode 100644 index 000000000000..e4841f23cab1 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-user-availability-schedule.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { userAvailabilityScheduleOutputSchema } from '../output-schemas'; + +export const getUserAvailabilityScheduleAction = createAction({ + auth: calendlyAuth, + name: 'get_user_availability_schedule', + classification: 'READ', + displayName: 'Get User Availability Schedule', + description: 'Gets one user availability schedule.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Calendly user availability schedule by URI or UUID (from List User Availability Schedules): name, default flag, timezone and rules. Read-only.', + idempotent: true, + }, + outputSchema: userAvailabilityScheduleOutputSchema, + props: { + schedule: Property.ShortText({ + displayName: 'Availability Schedule', + description: 'Availability schedule URI or UUID.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/user_availability_schedules/${calendlyCommon.toUuid({ value: propsValue.schedule })}`, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/get-user.ts b/packages/pieces/community/calendly/src/lib/actions/get-user.ts new file mode 100644 index 000000000000..f6d6c2772415 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/get-user.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { userOutputSchema } from '../output-schemas'; + +export const getUserAction = createAction({ + auth: calendlyAuth, + name: 'get_user', + classification: 'READ', + displayName: 'Get User', + description: 'Gets a Calendly user, or the connected user.', + audience: 'ai', + aiMetadata: { + description: + "Get a Calendly user's profile: URI, name, email, scheduling URL, timezone and current organization URI. Leave User empty to get the connected account (who am I); the returned uri and current_organization are the User and Organization values other Calendly actions need. Read-only.", + idempotent: true, + }, + outputSchema: userOutputSchema, + props: { + user: Property.ShortText({ + displayName: 'User', + description: 'User URI or UUID. Leave empty to get the connected user.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const path = calendlyCommon.isProvided(propsValue.user) + ? `/users/${calendlyCommon.toUuid({ value: propsValue.user })}` + : '/users/me'; + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.GET, + path, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-available-times.ts b/packages/pieces/community/calendly/src/lib/actions/list-available-times.ts new file mode 100644 index 000000000000..08973917832f --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-available-times.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { availableTimesOutputSchema } from '../output-schemas'; + +export const listAvailableTimesAction = createAction({ + auth: calendlyAuth, + name: 'list_available_times', + classification: 'SEARCH', + displayName: 'List Available Times', + description: 'Lists the open booking slots of an event type.', + audience: 'ai', + aiMetadata: { + description: + 'List the open start times of one Calendly event type between Start Time and End Time (at most 31 days apart, End Time in the future; a past Start Time is moved to now). Each slot has start_time, invitees_remaining and a scheduling_url that books that slot. Use it to answer "when is X free" or before sending someone a time. Not paginated. Read-only.', + idempotent: true, + }, + outputSchema: availableTimesOutputSchema, + props: { + eventType: calendlyCommon.eventType, + startTime: Property.DateTime({ + displayName: 'Start Time', + required: true, + }), + endTime: Property.DateTime({ + displayName: 'End Time', + description: 'At most 31 days after Start Time.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const window = calendlyCommon.validateTimeWindow({ + startTime: propsValue.startTime, + endTime: propsValue.endTime, + maxDays: 31, + }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/event_type_available_times', + queryParams: { + event_type: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + ...window, + }, + }); + return { items: response.collection, count: response.collection.length }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-event-invitees.ts b/packages/pieces/community/calendly/src/lib/actions/list-event-invitees.ts new file mode 100644 index 000000000000..3871688337de --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-event-invitees.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { eventInviteesOutputSchema } from '../output-schemas'; + +export const listEventInviteesAction = createAction({ + auth: calendlyAuth, + name: 'list_event_invitees', + classification: 'SEARCH', + displayName: 'List Event Invitees', + description: 'Lists the invitees of a booked meeting.', + audience: 'ai', + aiMetadata: { + description: + 'List the invitees of one booked Calendly meeting with each invitee URI, name, email, status, timezone, answers to booking questions, no-show record and cancel/reschedule URLs. Filter by email or status. The invitee uri is what Mark Invitee as No Show needs. Paginated with Page Token. Read-only.', + idempotent: true, + }, + outputSchema: eventInviteesOutputSchema, + props: { + scheduledEvent: calendlyCommon.scheduledEvent, + email: Property.ShortText({ + displayName: 'Email', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Active', value: 'active' }, + { label: 'Canceled', value: 'canceled' }, + ], + }, + }), + count: calendlyCommon.count, + pageToken: calendlyCommon.pageToken, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: `/scheduled_events/${calendlyCommon.toUuid({ value: propsValue.scheduledEvent })}/invitees`, + queryParams: { + email: propsValue.email?.trim(), + status: propsValue.status, + ...calendlyCommon.pageQuery({ count: propsValue.count, pageToken: propsValue.pageToken }), + }, + }); + return calendlyCommon.toPage({ response }); + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-event-type-hosts.ts b/packages/pieces/community/calendly/src/lib/actions/list-event-type-hosts.ts new file mode 100644 index 000000000000..e2b4d46f24eb --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-event-type-hosts.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { eventTypeHostsOutputSchema } from '../output-schemas'; + +export const listEventTypeHostsAction = createAction({ + auth: calendlyAuth, + name: 'list_event_type_hosts', + classification: 'SEARCH', + displayName: 'List Event Type Hosts', + description: 'Lists the hosts of an event type.', + audience: 'ai', + aiMetadata: { + description: + 'List the hosts (members) of one Calendly event type, each with the member user URI, name and email. Useful for group, collective and round-robin event types. Paginated with Page Token. Read-only.', + idempotent: true, + }, + outputSchema: eventTypeHostsOutputSchema, + props: { + eventType: calendlyCommon.eventType, + count: calendlyCommon.count, + pageToken: calendlyCommon.pageToken, + }, + async run({ auth, propsValue }) { + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/event_type_memberships', + queryParams: { + event_type: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + ...calendlyCommon.pageQuery({ count: propsValue.count, pageToken: propsValue.pageToken }), + }, + }); + return calendlyCommon.toPage({ response }); + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-event-types.ts b/packages/pieces/community/calendly/src/lib/actions/list-event-types.ts new file mode 100644 index 000000000000..95d63d884998 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-event-types.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { eventTypesOutputSchema } from '../output-schemas'; + +export const listEventTypesAction = createAction({ + auth: calendlyAuth, + name: 'list_event_types', + classification: 'SEARCH', + displayName: 'List Event Types', + description: 'Lists the event types of a user or organization.', + audience: 'ai', + aiMetadata: { + description: + "List Calendly event types (bookable meeting templates) with each URI, name, duration, kind, active flag and scheduling URL. Defaults to the connected user's event types; set Organization instead to list every event type in the organization (admin only). The returned uri is the Event Type value other actions need. Paginated with Page Token. Read-only.", + idempotent: true, + }, + outputSchema: eventTypesOutputSchema, + props: { + user: calendlyCommon.user, + organization: Property.ShortText({ + displayName: 'Organization', + description: 'Organization URI or UUID. When set, lists the whole organization instead of one user.', + required: false, + }), + active: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Active only', value: 'true' }, + { label: 'Inactive only', value: 'false' }, + ], + }, + }), + count: calendlyCommon.count, + pageToken: calendlyCommon.pageToken, + }, + async run({ auth, propsValue }) { + const owner = calendlyCommon.isProvided(propsValue.organization) + ? { organization: calendlyCommon.toUri({ resource: 'organizations', value: propsValue.organization }) } + : { user: await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.user }) }; + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/event_types', + queryParams: { + ...owner, + active: propsValue.active, + ...calendlyCommon.pageQuery({ count: propsValue.count, pageToken: propsValue.pageToken }), + }, + }); + return calendlyCommon.toPage({ response }); + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-organization-memberships.ts b/packages/pieces/community/calendly/src/lib/actions/list-organization-memberships.ts new file mode 100644 index 000000000000..bdda40582264 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-organization-memberships.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { organizationMembershipsOutputSchema } from '../output-schemas'; + +export const listOrganizationMembershipsAction = createAction({ + auth: calendlyAuth, + name: 'list_organization_memberships', + classification: 'SEARCH', + displayName: 'List Organization Members', + description: 'Lists the members of a Calendly organization.', + audience: 'ai', + aiMetadata: { + description: + "List the members of a Calendly organization with each membership URI, role (owner, admin, user) and the member's user URI, name and email. Leave Organization empty for the connected user's organization. Filter by email or role. Paginated with Page Token. Read-only.", + idempotent: true, + }, + outputSchema: organizationMembershipsOutputSchema, + props: { + organization: calendlyCommon.organization, + email: Property.ShortText({ + displayName: 'Email', + description: 'Only return the member with this email.', + required: false, + }), + role: Property.StaticDropdown({ + displayName: 'Role', + required: false, + options: { + options: [ + { label: 'Owner', value: 'owner' }, + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + }), + count: calendlyCommon.count, + pageToken: calendlyCommon.pageToken, + }, + async run({ auth, propsValue }) { + const organization = await calendlyCommon.resolveOrganizationUri({ + token: auth.secret_text, + organization: propsValue.organization, + }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/organization_memberships', + queryParams: { + organization, + email: propsValue.email?.trim(), + role: propsValue.role, + ...calendlyCommon.pageQuery({ count: propsValue.count, pageToken: propsValue.pageToken }), + }, + }); + return calendlyCommon.toPage({ response }); + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-scheduled-events.ts b/packages/pieces/community/calendly/src/lib/actions/list-scheduled-events.ts new file mode 100644 index 000000000000..72bcceabb31f --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-scheduled-events.ts @@ -0,0 +1,83 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { scheduledEventsOutputSchema } from '../output-schemas'; + +export const listScheduledEventsAction = createAction({ + auth: calendlyAuth, + name: 'list_scheduled_events', + classification: 'SEARCH', + displayName: 'List Scheduled Events', + description: 'Lists booked Calendly meetings.', + audience: 'ai', + aiMetadata: { + description: + "List booked Calendly meetings with each URI, name, status, start and end time, event type, location and invitee counts. Defaults to the connected user's meetings; set Organization to list the whole organization (admin only). Filter by invitee email, status and a start-time range (UTC ISO 8601). Invitees are not included; use List Event Invitees. Paginated with Page Token. Read-only.", + idempotent: true, + }, + outputSchema: scheduledEventsOutputSchema, + props: { + user: calendlyCommon.user, + organization: Property.ShortText({ + displayName: 'Organization', + description: 'Organization URI or UUID. When set, lists the whole organization instead of one user.', + required: false, + }), + inviteeEmail: Property.ShortText({ + displayName: 'Invitee Email', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Active', value: 'active' }, + { label: 'Canceled', value: 'canceled' }, + ], + }, + }), + minStartTime: Property.DateTime({ + displayName: 'Starts After', + required: false, + }), + maxStartTime: Property.DateTime({ + displayName: 'Starts Before', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort', + required: false, + defaultValue: 'start_time:asc', + options: { + options: [ + { label: 'Start time, earliest first', value: 'start_time:asc' }, + { label: 'Start time, latest first', value: 'start_time:desc' }, + ], + }, + }), + count: calendlyCommon.count, + pageToken: calendlyCommon.pageToken, + }, + async run({ auth, propsValue }) { + const owner = calendlyCommon.isProvided(propsValue.organization) + ? { organization: calendlyCommon.toUri({ resource: 'organizations', value: propsValue.organization }) } + : { user: await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.user }) }; + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/scheduled_events', + queryParams: { + ...owner, + invitee_email: propsValue.inviteeEmail?.trim(), + status: propsValue.status, + min_start_time: propsValue.minStartTime, + max_start_time: propsValue.maxStartTime, + sort: propsValue.sort, + ...calendlyCommon.pageQuery({ count: propsValue.count, pageToken: propsValue.pageToken }), + }, + }); + return calendlyCommon.toPage({ response }); + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-user-availability-schedules.ts b/packages/pieces/community/calendly/src/lib/actions/list-user-availability-schedules.ts new file mode 100644 index 000000000000..485d41c66a81 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-user-availability-schedules.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { userAvailabilitySchedulesOutputSchema } from '../output-schemas'; + +export const listUserAvailabilitySchedulesAction = createAction({ + auth: calendlyAuth, + name: 'list_user_availability_schedules', + classification: 'SEARCH', + displayName: 'List User Availability Schedules', + description: "Lists a user's availability schedules.", + audience: 'ai', + aiMetadata: { + description: + "List a Calendly user's availability schedules (working hours), each with URI, name, default flag, timezone and weekday/date rules. Defaults to the connected user. Not paginated. Read-only.", + idempotent: true, + }, + outputSchema: userAvailabilitySchedulesOutputSchema, + props: { + user: calendlyCommon.user, + }, + async run({ auth, propsValue }) { + const user = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.user }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/user_availability_schedules', + queryParams: { user }, + }); + return { items: response.collection, count: response.collection.length }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-user-busy-times.ts b/packages/pieces/community/calendly/src/lib/actions/list-user-busy-times.ts new file mode 100644 index 000000000000..f2ced1ef34a4 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-user-busy-times.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { userBusyTimesOutputSchema } from '../output-schemas'; + +export const listUserBusyTimesAction = createAction({ + auth: calendlyAuth, + name: 'list_user_busy_times', + classification: 'SEARCH', + displayName: 'List User Busy Times', + description: "Lists a user's busy time blocks.", + audience: 'ai', + aiMetadata: { + description: + "List a Calendly user's busy intervals between Start Time and End Time (at most 7 days apart, End Time in the future; a past Start Time is moved to now), in ascending order. Includes Calendly bookings and events from connected calendars that have conflict checking on. Each item has type (calendly or external), start_time and end_time. Defaults to the connected user. Not paginated. Read-only.", + idempotent: true, + }, + outputSchema: userBusyTimesOutputSchema, + props: { + user: calendlyCommon.user, + startTime: Property.DateTime({ + displayName: 'Start Time', + required: true, + }), + endTime: Property.DateTime({ + displayName: 'End Time', + description: 'At most 7 days after Start Time.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const window = calendlyCommon.validateTimeWindow({ + startTime: propsValue.startTime, + endTime: propsValue.endTime, + maxDays: 7, + }); + const user = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.user }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/user_busy_times', + queryParams: { user, ...window }, + }); + return { items: response.collection, count: response.collection.length }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/list-user-locations.ts b/packages/pieces/community/calendly/src/lib/actions/list-user-locations.ts new file mode 100644 index 000000000000..ee93b963f322 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/list-user-locations.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyCollection, CalendlyRecord } from '../common'; +import { userLocationsOutputSchema } from '../output-schemas'; + +export const listUserLocationsAction = createAction({ + auth: calendlyAuth, + name: 'list_user_locations', + classification: 'SEARCH', + displayName: 'List User Meeting Locations', + description: 'Lists the meeting location types a user has set up.', + audience: 'ai', + aiMetadata: { + description: + 'List the meeting location kinds a Calendly user can use (zoom_conference, google_conference, physical, ...) and whether each one is connected. Check this before choosing a location for Create Event Type or Create One-Off Meeting Link. Defaults to the connected user. Read-only.', + idempotent: true, + }, + outputSchema: userLocationsOutputSchema, + props: { + user: calendlyCommon.user, + }, + async run({ auth, propsValue }) { + const user = await calendlyCommon.resolveUserUri({ token: auth.secret_text, user: propsValue.user }); + const response = await calendlyCommon.calendlyRequest>({ + token: auth.secret_text, + method: HttpMethod.GET, + path: '/locations', + queryParams: { user }, + }); + return { items: response.collection, count: response.collection.length }; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/update-event-type-availability.ts b/packages/pieces/community/calendly/src/lib/actions/update-event-type-availability.ts new file mode 100644 index 000000000000..5d84879266a9 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/update-event-type-availability.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { updateEventTypeAvailabilityOutputSchema } from '../output-schemas'; + +export const updateEventTypeAvailabilityAction = createAction({ + auth: calendlyAuth, + name: 'update_event_type_availability', + classification: 'WRITE', + displayName: 'Update Event Type Availability', + description: 'Replaces the availability schedule of an event type.', + audience: 'ai', + aiMetadata: { + description: + 'Replace the availability schedule of one Calendly event type. Rules REPLACES every existing rule, so first call Get Event Type Availability, change the rules you need and send back the full list. Each rule is {"type":"wday","wday":"monday","intervals":[{"from":"09:00","to":"17:00"}]} or {"type":"date","date":"2026-12-24","intervals":[]} (empty intervals = unavailable that day).', + idempotent: true, + }, + outputSchema: updateEventTypeAvailabilityOutputSchema, + props: { + eventType: calendlyCommon.eventType, + timezone: Property.ShortText({ + displayName: 'Timezone', + description: 'IANA timezone such as America/New_York.', + required: true, + }), + rules: Property.Json({ + displayName: 'Rules', + description: + 'Full list of rules, replacing the current ones. Example: [{"type":"wday","wday":"monday","intervals":[{"from":"09:00","to":"17:00"}]}]', + required: true, + }), + }, + async run({ auth, propsValue }) { + const rules: unknown = propsValue.rules; + if (!Array.isArray(rules)) { + throw new Error('Rules must be a JSON array of availability rules.'); + } + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.PATCH, + path: '/event_type_availability_schedules', + queryParams: { + event_type: calendlyCommon.toUri({ resource: 'event_types', value: propsValue.eventType }), + }, + body: { + availability_rule: { timezone: propsValue.timezone.trim(), rules }, + }, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/actions/update-event-type.ts b/packages/pieces/community/calendly/src/lib/actions/update-event-type.ts new file mode 100644 index 000000000000..be143511050a --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/actions/update-event-type.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calendlyAuth } from '../auth'; +import { calendlyCommon, CalendlyRecord } from '../common'; +import { eventTypeOutputSchema } from '../output-schemas'; + +export const updateEventTypeAction = createAction({ + auth: calendlyAuth, + name: 'update_event_type', + classification: 'WRITE', + displayName: 'Update Event Type', + description: 'Updates a one-on-one event type.', + audience: 'ai', + aiMetadata: { + description: + 'Update a one-on-one (solo) Calendly event type: name, duration, description, color, active status or locations. Only the fields you set are changed, except Locations, which replaces the whole location list when set. Group, collective and round-robin event types cannot be updated through the API.', + idempotent: true, + }, + outputSchema: eventTypeOutputSchema, + props: { + eventType: calendlyCommon.eventType, + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + ...calendlyCommon.eventTypeFields, + }, + async run({ auth, propsValue }) { + const body = { + ...(calendlyCommon.isProvided(propsValue.name) ? { name: propsValue.name } : {}), + ...calendlyCommon.eventTypeBody(propsValue), + }; + if (Object.keys(body).length === 0) { + throw new Error('Set at least one field to update.'); + } + const response = await calendlyCommon.calendlyRequest<{ resource: CalendlyRecord }>({ + token: auth.secret_text, + method: HttpMethod.PATCH, + path: `/event_types/${calendlyCommon.toUuid({ value: propsValue.eventType })}`, + body, + }); + return response.resource; + }, +}); diff --git a/packages/pieces/community/calendly/src/lib/auth.ts b/packages/pieces/community/calendly/src/lib/auth.ts index 0adb73fa024c..a355a6bc48f8 100644 --- a/packages/pieces/community/calendly/src/lib/auth.ts +++ b/packages/pieces/community/calendly/src/lib/auth.ts @@ -5,7 +5,8 @@ const markdown = ` ## Obtain your Calendly Personal Token 1. Go to https://calendly.com/integrations/api_webhooks 2. Click on "Create New Token" -3. Copy the token and paste it in the field below +3. Select these scopes: \`users:read\`, \`organizations:read\`, \`event_types:read\`, \`event_types:write\`, \`availability:read\`, \`availability:write\`, \`scheduled_events:read\`, \`scheduled_events:write\`, \`scheduling_links:write\`, \`shares:write\`, \`locations:read\`, \`webhooks:read\`, \`webhooks:write\` +4. Copy the token and paste it in the field below `; export const calendlyAuth = PieceAuth.SecretText({ @@ -14,7 +15,7 @@ export const calendlyAuth = PieceAuth.SecretText({ description: markdown, validate: async ({ auth }) => { try { - calendlyCommon.getUser(auth); + await calendlyCommon.getUser(auth); return { valid: true, }; diff --git a/packages/pieces/community/calendly/src/lib/common/index.ts b/packages/pieces/community/calendly/src/lib/common/index.ts index d629d62f3afd..dced857f00f8 100644 --- a/packages/pieces/community/calendly/src/lib/common/index.ts +++ b/packages/pieces/community/calendly/src/lib/common/index.ts @@ -1,24 +1,242 @@ import { Property } from '@activepieces/pieces-framework'; import { - HttpRequest, + AuthenticationType, + HttpError, HttpMethod, httpClient, } from '@activepieces/pieces-common'; -interface CalendlyUser { - /**User uri */ - uri: string; - email: string; - name: string; - /**Organization uri */ - current_organization: string; +async function calendlyRequest({ + token, + method, + path, + queryParams, + body, +}: CalendlyRequest): Promise { + const response = await httpClient + .sendRequest({ + method, + url: `${BASE_URL}${path}`, + queryParams: compactQuery({ query: queryParams ?? {} }), + body, + authentication: { type: AuthenticationType.BEARER_TOKEN, token }, + }) + .catch((error: unknown) => { + throw toCalendlyError({ error }); + }); + return response.body; } -export interface CalendlyWebhookInformation { - webhookId: string; + +async function getUser(personalToken: string): Promise { + const response = await calendlyRequest<{ resource: CalendlyUser }>({ + token: personalToken, + method: HttpMethod.GET, + path: '/users/me', + }); + return response.resource; +} + +async function resolveUserUri({ + token, + user, +}: { + token: string; + user: string | undefined; +}): Promise { + if (isProvided(user)) { + return toUri({ resource: 'users', value: user }); + } + const currentUser = await getUser(token); + return currentUser.uri; +} + +async function resolveOrganizationUri({ + token, + organization, +}: { + token: string; + organization: string | undefined; +}): Promise { + if (isProvided(organization)) { + return toUri({ resource: 'organizations', value: organization }); + } + const currentUser = await getUser(token); + return currentUser.current_organization; +} + +function toUri({ resource, value }: { resource: string; value: string }): string { + const trimmed = value.trim(); + return trimmed.startsWith('https://') + ? trimmed + : `${BASE_URL}/${resource}/${encodeURIComponent(trimmed)}`; +} + +function toUuid({ value }: { value: string }): string { + const segments = value.trim().replace(/\/+$/, '').split('/'); + return encodeURIComponent(segments[segments.length - 1]); +} + +function toInviteeUri({ + invitee, + scheduledEvent, +}: { + invitee: string; + scheduledEvent: string | undefined; +}): string { + const trimmed = invitee.trim(); + if (trimmed.startsWith('https://')) { + return trimmed; + } + if (!isProvided(scheduledEvent)) { + throw new Error('Pass the full invitee URI, or set Scheduled Event when Invitee is a UUID.'); + } + return `${BASE_URL}/scheduled_events/${toUuid({ value: scheduledEvent })}/invitees/${encodeURIComponent(trimmed)}`; +} + +function UuidFromUri(uri: string): string | undefined { + return uri.split('/').pop(); +} + +function authorizationHeader(personalToken: string): string { + return `Bearer ${personalToken}`; +} + +function pageQuery({ + count, + pageToken, +}: { + count: number | undefined; + pageToken: string | undefined; +}): Record { + return { + count: String( + Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(count ?? DEFAULT_PAGE_SIZE))) + ), + page_token: isProvided(pageToken) ? pageToken.trim() : undefined, + }; +} + +function toPage({ response }: { response: CalendlyCollection }) { + return { + items: response.collection, + count: response.collection.length, + next_page_token: response.pagination?.next_page_token ?? null, + }; +} + +function validateTimeWindow({ + startTime, + endTime, + maxDays, +}: { + startTime: string; + endTime: string; + maxDays: number; +}): { start_time: string; end_time: string } { + const start = Date.parse(startTime); + const end = Date.parse(endTime); + if (Number.isNaN(start) || Number.isNaN(end)) { + throw new Error('Start Time and End Time must be valid ISO 8601 date-times.'); + } + const effectiveStart = Math.max(start, Date.now() + FUTURE_START_BUFFER_MS); + if (end <= effectiveStart) { + throw new Error('End Time must be after Start Time and in the future.'); + } + if (end - effectiveStart > maxDays * DAY_MS) { + throw new Error(`The range between Start Time and End Time can be at most ${maxDays} days.`); + } + return { + start_time: new Date(effectiveStart).toISOString(), + end_time: new Date(end).toISOString(), + }; +} + +function isProvided(value: string | undefined | null): value is string { + return value !== undefined && value !== null && value.trim().length > 0; +} + +function compactQuery({ + query, +}: { + query: Record; +}): Record { + return Object.fromEntries( + Object.entries(query).filter( + (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== '' + ) + ); +} + +function toCalendlyError({ error }: { error: unknown }): unknown { + if (!(error instanceof HttpError)) { + return error; + } + const { status, body } = error.response; + if (!isRecord(body)) { + return error; + } + const requiredScopes = Array.isArray(body['required_scopes']) + ? body['required_scopes'].filter((scope): scope is string => typeof scope === 'string') + : []; + if (status === 403 && requiredScopes.length > 0) { + return new Error( + `Your Calendly Personal Access Token is missing the scope(s): ${requiredScopes.join(', ')}. Create a new token with these scopes and reconnect.` + ); + } + const title = typeof body['title'] === 'string' ? body['title'] : 'Calendly error'; + const message = typeof body['message'] === 'string' ? body['message'] : ''; + const details = Array.isArray(body['details']) ? ` ${JSON.stringify(body['details'])}` : ''; + return new Error(`${title} (${status}): ${message}${details}`.trim()); +} + +function eventTypeBody({ + duration, + description, + color, + active, + locations, +}: EventTypeFieldValues): Record { + const locationList = (locations ?? []).filter(isRecord).map((location) => + Object.fromEntries( + Object.entries(location).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '' + ) + ) + ); + return { + ...(duration !== undefined && duration !== null ? { duration } : {}), + ...(isProvided(description) ? { description } : {}), + ...(isProvided(color) ? { color: color.trim() } : {}), + ...(isProvided(active) ? { active: active === 'true' } : {}), + ...(locationList.length > 0 ? { locations: locationList } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } +const BASE_URL = 'https://api.calendly.com'; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const DAY_MS = 24 * 60 * 60 * 1000; +const FUTURE_START_BUFFER_MS = 60 * 1000; + +const LOCATION_KINDS = [ + { label: 'Zoom', value: 'zoom_conference' }, + { label: 'Google Meet', value: 'google_conference' }, + { label: 'Microsoft Teams', value: 'microsoft_teams_conference' }, + { label: 'Webex', value: 'webex_conference' }, + { label: 'GoToMeeting', value: 'gotomeeting_conference' }, + { label: 'In person', value: 'physical' }, + { label: 'Phone call (host calls invitee)', value: 'outbound_call' }, + { label: 'Phone call (invitee calls host)', value: 'inbound_call' }, + { label: 'Custom', value: 'custom' }, + { label: 'Ask invitee', value: 'ask_invitee' }, +]; + export const calendlyCommon = { - baseUrl: 'https://api.calendly.com', + baseUrl: BASE_URL, scope: Property.StaticDropdown({ displayName: 'Scope', required: true, @@ -30,19 +248,150 @@ export const calendlyCommon = { disabled: false, }, }), - getUser: async (personalToken: string) => { - const request: HttpRequest = { - method: HttpMethod.GET, - url: `${calendlyCommon.baseUrl}/users/me`, - headers: { - Authorization: calendlyCommon.authorizationHeader(personalToken), + user: Property.ShortText({ + displayName: 'User', + description: + 'User URI (https://api.calendly.com/users/...) or UUID. Leave empty to use the connected user.', + required: false, + }), + organization: Property.ShortText({ + displayName: 'Organization', + description: + "Organization URI (https://api.calendly.com/organizations/...) or UUID. Leave empty to use the connected user's organization.", + required: false, + }), + eventType: Property.ShortText({ + displayName: 'Event Type', + description: 'Event type URI (https://api.calendly.com/event_types/...) or UUID, from List Event Types.', + required: true, + }), + scheduledEvent: Property.ShortText({ + displayName: 'Scheduled Event', + description: + 'Scheduled event URI (https://api.calendly.com/scheduled_events/...) or UUID, from List Scheduled Events or a trigger.', + required: true, + }), + invitee: Property.ShortText({ + displayName: 'Invitee', + description: + 'Invitee URI (https://api.calendly.com/scheduled_events/.../invitees/...), or its UUID together with Scheduled Event.', + required: true, + }), + optionalScheduledEvent: Property.ShortText({ + displayName: 'Scheduled Event', + description: 'Scheduled event URI or UUID. Only needed when Invitee is a UUID.', + required: false, + }), + count: Property.Number({ + displayName: 'Page Size', + description: 'How many items to return, from 1 to 100.', + required: false, + defaultValue: DEFAULT_PAGE_SIZE, + }), + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'Next Page Token from a previous call. Leave empty for the first page.', + required: false, + }), + eventTypeFields: { + duration: Property.Number({ + displayName: 'Duration (minutes)', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + required: false, + }), + color: Property.ShortText({ + displayName: 'Color', + description: 'Hex color, for example #ff5733.', + required: false, + }), + active: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Active', value: 'true' }, + { label: 'Inactive', value: 'false' }, + ], }, - }; - const response = await httpClient.sendRequest<{ resource: CalendlyUser }>( - request - ); - return response.body.resource; + }), + locations: Property.Array({ + displayName: 'Locations', + description: 'Replaces all locations of the event type when set.', + required: false, + properties: { + kind: Property.StaticDropdown({ + displayName: 'Kind', + required: true, + options: { + options: LOCATION_KINDS.map((kind) => ({ label: kind.label, value: kind.value })), + }, + }), + location: Property.ShortText({ + displayName: 'Location', + description: 'Address or link, for physical and custom locations.', + required: false, + }), + phone_number: Property.ShortText({ + displayName: 'Phone Number', + description: 'Required for inbound calls.', + required: false, + }), + additional_info: Property.ShortText({ + displayName: 'Additional Info', + required: false, + }), + }, + }), }, - authorizationHeader: (personalToken: string) => `Bearer ${personalToken}`, - UuidFromUri: (uri: string) => uri.split('/').pop(), + calendlyRequest, + getUser, + resolveUserUri, + resolveOrganizationUri, + toUri, + toUuid, + toInviteeUri, + pageQuery, + toPage, + validateTimeWindow, + eventTypeBody, + isProvided, + authorizationHeader, + UuidFromUri, +}; + +export interface CalendlyWebhookInformation { + webhookId: string; +} + +export type CalendlyRecord = Record; + +export type CalendlyCollection = { + collection: T[]; + pagination?: { next_page_token: string | null }; +}; + +type EventTypeFieldValues = { + duration?: number | null; + description?: string | null; + color?: string | null; + active?: string | null; + locations?: unknown[] | null; +}; + +type CalendlyRequest = { + token: string; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: unknown; +}; + +type CalendlyUser = { + uri: string; + email: string; + name: string; + current_organization: string; }; diff --git a/packages/pieces/community/calendly/src/lib/output-schemas.ts b/packages/pieces/community/calendly/src/lib/output-schemas.ts new file mode 100644 index 000000000000..11a931b51d24 --- /dev/null +++ b/packages/pieces/community/calendly/src/lib/output-schemas.ts @@ -0,0 +1,428 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const pageFields: OutputSchemaField[] = [ + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'next_page_token', + label: 'Next Page Token', + description: 'Pass as Page Token to fetch the next page. Empty on the last page.', + }, +]; + +const userFields: OutputSchemaField[] = [ + { key: 'uri', label: 'User URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'slug', label: 'Slug' }, + { key: 'scheduling_url', label: 'Scheduling URL', format: 'url' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'avatar_url', label: 'Avatar', format: 'image' }, + { key: 'locale', label: 'Locale' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const membershipFields: OutputSchemaField[] = [ + { key: 'uri', label: 'Membership URI', format: 'url' }, + { key: 'role', label: 'Role' }, + { key: 'organization', label: 'Organization URI', format: 'url' }, + { key: 'user', label: 'User', children: userFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const eventTypeFields: OutputSchemaField[] = [ + { key: 'uri', label: 'Event Type URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'scheduling_url', label: 'Scheduling URL', format: 'url' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'duration', label: 'Duration (minutes)', format: 'number' }, + { key: 'kind', label: 'Kind' }, + { key: 'type', label: 'Type' }, + { key: 'pooling_type', label: 'Pooling Type' }, + { key: 'secret', label: 'Secret', format: 'boolean' }, + { key: 'color', label: 'Color' }, + { key: 'description_plain', label: 'Description' }, + { key: 'locations', label: 'Locations', labelKey: 'kind', listItems: [{ key: 'kind', label: 'Kind' }, { key: 'location', label: 'Location' }] }, + { + key: 'profile', + label: 'Host', + children: [ + { key: 'name', label: 'Name' }, + { key: 'owner', label: 'Owner URI', format: 'url' }, + { key: 'type', label: 'Type' }, + ], + }, + { + key: 'custom_questions', + label: 'Booking Questions', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Question' }, + { key: 'type', label: 'Type' }, + { key: 'required', label: 'Required', format: 'boolean' }, + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + ], + }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const availabilityRuleFields: OutputSchemaField[] = [ + { key: 'type', label: 'Type' }, + { key: 'wday', label: 'Weekday' }, + { key: 'date', label: 'Date', format: 'date' }, + { + key: 'intervals', + label: 'Intervals', + labelKey: 'from', + listItems: [ + { key: 'from', label: 'From' }, + { key: 'to', label: 'To' }, + ], + }, +]; + +const availabilityScheduleFields: OutputSchemaField[] = [ + { key: 'uri', label: 'Schedule URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'default', label: 'Default', format: 'boolean' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'user', label: 'User URI', format: 'url' }, + { key: 'rules', label: 'Rules', labelKey: 'wday', listItems: availabilityRuleFields }, +]; + +const eventTypeAvailabilityFields: OutputSchemaField[] = [ + { key: 'event_type', label: 'Event Type URI', format: 'url' }, + { key: 'availability_setting', label: 'Availability Setting' }, + { + key: 'availability_rule', + label: 'Availability', + children: [ + { key: 'uri', label: 'Schedule URI', format: 'url' }, + { key: 'name', label: 'Schedule Name' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'rules', label: 'Rules', labelKey: 'wday', listItems: availabilityRuleFields }, + ], + }, +]; + +const schedulingLinkFields: OutputSchemaField[] = [ + { key: 'booking_url', label: 'Booking URL', format: 'url' }, + { key: 'owner', label: 'Event Type URI', format: 'url' }, + { key: 'owner_type', label: 'Owner Type' }, +]; + +const cancellationFields: OutputSchemaField[] = [ + { key: 'canceled_by', label: 'Canceled By' }, + { key: 'canceler_type', label: 'Canceler Type' }, + { key: 'reason', label: 'Reason' }, + { key: 'created_at', label: 'Canceled At', format: 'datetime' }, +]; + +const scheduledEventFields: OutputSchemaField[] = [ + { key: 'uri', label: 'Event URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'start_time', label: 'Start Time', format: 'datetime' }, + { key: 'end_time', label: 'End Time', format: 'datetime' }, + { key: 'event_type', label: 'Event Type URI', format: 'url' }, + { + key: 'location', + label: 'Location', + children: [ + { key: 'type', label: 'Type' }, + { key: 'join_url', label: 'Join URL', format: 'url' }, + { key: 'location', label: 'Location' }, + { key: 'status', label: 'Status' }, + ], + }, + { + key: 'invitees_counter', + label: 'Invitees', + children: [ + { key: 'active', label: 'Active', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'limit', label: 'Limit', format: 'number' }, + ], + }, + { + key: 'event_memberships', + label: 'Hosts', + labelKey: 'user_name', + listItems: [ + { key: 'user', label: 'User URI', format: 'url' }, + { key: 'user_name', label: 'Name' }, + { key: 'user_email', label: 'Email', format: 'email' }, + ], + }, + { key: 'cancellation', label: 'Cancellation', children: cancellationFields }, + { key: 'meeting_notes_plain', label: 'Meeting Notes' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const inviteeFields: OutputSchemaField[] = [ + { key: 'uri', label: 'Invitee URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'first_name', label: 'First Name' }, + { key: 'last_name', label: 'Last Name' }, + { key: 'status', label: 'Status' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'event', label: 'Event URI', format: 'url' }, + { key: 'rescheduled', label: 'Rescheduled', format: 'boolean' }, + { key: 'cancel_url', label: 'Cancel URL', format: 'url' }, + { key: 'reschedule_url', label: 'Reschedule URL', format: 'url' }, + { + key: 'questions_and_answers', + label: 'Answers', + labelKey: 'question', + listItems: [ + { key: 'question', label: 'Question' }, + { key: 'answer', label: 'Answer' }, + ], + }, + { + key: 'tracking', + label: 'Tracking', + children: [ + { key: 'utm_source', label: 'UTM Source' }, + { key: 'utm_medium', label: 'UTM Medium' }, + { key: 'utm_campaign', label: 'UTM Campaign' }, + { key: 'utm_content', label: 'UTM Content' }, + { key: 'utm_term', label: 'UTM Term' }, + ], + }, + { + key: 'no_show', + label: 'No Show', + children: [ + { key: 'uri', label: 'No Show URI', format: 'url' }, + { key: 'created_at', label: 'Marked At', format: 'datetime' }, + ], + }, + { + key: 'payment', + label: 'Payment', + children: [ + { key: 'external_id', label: 'Payment ID' }, + { key: 'provider', label: 'Provider' }, + { key: 'amount', label: 'Amount', format: 'number' }, + { key: 'currency', label: 'Currency' }, + { key: 'successful', label: 'Successful', format: 'boolean' }, + { key: 'terms', label: 'Terms' }, + ], + }, + { + key: 'reconfirmation', + label: 'Reconfirmation', + children: [ + { key: 'created_at', label: 'Requested At', format: 'datetime' }, + { key: 'confirmed_at', label: 'Confirmed At', format: 'datetime' }, + ], + }, + { key: 'text_reminder_number', label: 'Text Reminder Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +export const userOutputSchema: OutputSchema = { + fields: [...userFields, { key: 'current_organization', label: 'Organization URI', format: 'url' }], +}; + +export const organizationOutputSchema: OutputSchema = { + fields: [ + { key: 'uri', label: 'Organization URI', format: 'url' }, + { key: 'name', label: 'Name' }, + { key: 'plan', label: 'Plan' }, + { key: 'stage', label: 'Stage' }, + { key: 'kind', label: 'Kind' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + ], +}; + +export const organizationMembershipsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Members', labelKey: 'user.name', listItems: membershipFields }, ...pageFields], +}; + +export const organizationMembershipOutputSchema: OutputSchema = { + fields: membershipFields, +}; + +export const findUserOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'user', label: 'User', children: userFields }, + { key: 'role', label: 'Role' }, + { key: 'membership_uri', label: 'Membership URI', format: 'url' }, + ], +}; + +export const eventTypesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Event Types', labelKey: 'name', listItems: eventTypeFields }, ...pageFields], +}; + +export const eventTypeOutputSchema: OutputSchema = { + fields: eventTypeFields, +}; + +export const eventTypeHostsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Hosts', + labelKey: 'member.name', + listItems: [ + { key: 'uri', label: 'Host Membership URI', format: 'url' }, + { key: 'member', label: 'Host', children: userFields }, + { key: 'created_at', label: 'Added At', format: 'datetime' }, + ], + }, + ...pageFields, + ], +}; + +export const availableTimesOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Available Times', + labelKey: 'start_time', + listItems: [ + { key: 'start_time', label: 'Start Time', format: 'datetime' }, + { key: 'status', label: 'Status' }, + { key: 'invitees_remaining', label: 'Spots Left', format: 'number' }, + { key: 'scheduling_url', label: 'Booking URL', format: 'url' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const userBusyTimesOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Busy Times', + labelKey: 'start_time', + listItems: [ + { key: 'type', label: 'Type' }, + { key: 'start_time', label: 'Start Time', format: 'datetime' }, + { key: 'end_time', label: 'End Time', format: 'datetime' }, + { key: 'buffered_start_time', label: 'Buffered Start Time', format: 'datetime' }, + { key: 'buffered_end_time', label: 'Buffered End Time', format: 'datetime' }, + { key: 'event', label: 'Event', children: [{ key: 'uri', label: 'Event URI', format: 'url' }] }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const eventTypeAvailabilityOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Availability Schedules', labelKey: 'availability_rule.name', listItems: eventTypeAvailabilityFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const updateEventTypeAvailabilityOutputSchema: OutputSchema = { + fields: eventTypeAvailabilityFields, +}; + +export const userAvailabilitySchedulesOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Availability Schedules', labelKey: 'name', listItems: availabilityScheduleFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const userAvailabilityScheduleOutputSchema: OutputSchema = { + fields: availabilityScheduleFields, +}; + +export const userLocationsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Locations', + labelKey: 'kind', + listItems: [ + { key: 'kind', label: 'Kind' }, + { key: 'connected', label: 'Connected', format: 'boolean' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const schedulingLinkOutputSchema: OutputSchema = { + fields: schedulingLinkFields, +}; + +export const shareOutputSchema: OutputSchema = { + fields: [ + { key: 'uri', label: 'Share URI', format: 'url' }, + { key: 'scheduling_links', label: 'Booking Links', labelKey: 'booking_url', listItems: schedulingLinkFields }, + { + key: 'share_override', + label: 'Overrides', + children: [ + { key: 'name', label: 'Name' }, + { key: 'duration', label: 'Duration (minutes)', format: 'number' }, + { key: 'period_type', label: 'Booking Window' }, + { key: 'start_date', label: 'Start Date', format: 'date' }, + { key: 'end_date', label: 'End Date', format: 'date' }, + { key: 'hide_location', label: 'Hide Location', format: 'boolean' }, + ], + }, + ], +}; + +export const scheduledEventsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Events', labelKey: 'name', listItems: scheduledEventFields }, ...pageFields], +}; + +export const scheduledEventOutputSchema: OutputSchema = { + fields: scheduledEventFields, +}; + +export const findInviteeBookingsOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'events', label: 'Events', labelKey: 'start_time', listItems: scheduledEventFields }, + ], +}; + +export const cancellationOutputSchema: OutputSchema = { + fields: cancellationFields, +}; + +export const eventInviteesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Invitees', labelKey: 'name', listItems: inviteeFields }, ...pageFields], +}; + +export const eventInviteeOutputSchema: OutputSchema = { + fields: inviteeFields, +}; + +export const inviteeTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Webhook Event' }, + { key: 'created_at', label: 'Received At', format: 'datetime' }, + { + key: 'payload', + label: 'Invitee', + children: [ + ...inviteeFields, + { key: 'cancellation', label: 'Cancellation', children: cancellationFields }, + { + key: 'scheduled_event', + label: 'Scheduled Event', + children: scheduledEventFields, + }, + ], + }, + ], +}; diff --git a/packages/pieces/community/calendly/src/lib/trigger/invitee-canceled.trigger.ts b/packages/pieces/community/calendly/src/lib/trigger/invitee-canceled.trigger.ts index e8a2f6d42184..932749a88ecb 100644 --- a/packages/pieces/community/calendly/src/lib/trigger/invitee-canceled.trigger.ts +++ b/packages/pieces/community/calendly/src/lib/trigger/invitee-canceled.trigger.ts @@ -7,6 +7,7 @@ import { } from '@activepieces/pieces-common'; import { calendlyCommon, CalendlyWebhookInformation } from '../common'; import { calendlyAuth } from '../auth'; +import { inviteeTriggerOutputSchema } from '../output-schemas'; const triggerNameInStore = 'calendly_invitee_canceled_trigger'; @@ -64,6 +65,7 @@ export const calendlyInviteeCanceled = createTrigger({ uri: 'https://api.calendly.com/scheduled_events/AAAAAAAAAAAaA/invitees/AAAAAAAA', }, }, + outputSchema: inviteeTriggerOutputSchema, type: TriggerStrategy.WEBHOOK, async onEnable(context) { const calendlyUser = await calendlyCommon.getUser(context.auth.secret_text); diff --git a/packages/pieces/community/calendly/src/lib/trigger/invitee-created.trigger.ts b/packages/pieces/community/calendly/src/lib/trigger/invitee-created.trigger.ts index 845308afc888..bc83542e76fd 100644 --- a/packages/pieces/community/calendly/src/lib/trigger/invitee-created.trigger.ts +++ b/packages/pieces/community/calendly/src/lib/trigger/invitee-created.trigger.ts @@ -7,6 +7,7 @@ import { } from '@activepieces/pieces-common'; import { calendlyCommon, CalendlyWebhookInformation } from '../common'; import { calendlyAuth } from '../auth'; +import { inviteeTriggerOutputSchema } from '../output-schemas'; const triggerNameInStore = 'calendly_invitee_created_trigger'; @@ -50,6 +51,7 @@ export const calendlyInviteeCreated = createTrigger({ uri: 'https://api.calendly.com/scheduled_events/AAAAAAAAAAAaA/invitees/AAAAAAAAAAAA', }, }, + outputSchema: inviteeTriggerOutputSchema, type: TriggerStrategy.WEBHOOK, async onEnable(context) { const calendlyUser = await calendlyCommon.getUser(context.auth.secret_text); diff --git a/packages/pieces/community/facebook-pages/package.json b/packages/pieces/community/facebook-pages/package.json index e5f02644c083..a8ff5b7b5245 100644 --- a/packages/pieces/community/facebook-pages/package.json +++ b/packages/pieces/community/facebook-pages/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-facebook-pages", - "version": "0.2.8", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/facebook-pages/src/index.ts b/packages/pieces/community/facebook-pages/src/index.ts index 68f415972a4d..206d5e28446c 100644 --- a/packages/pieces/community/facebook-pages/src/index.ts +++ b/packages/pieces/community/facebook-pages/src/index.ts @@ -1,47 +1,55 @@ -import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; - -import { PieceCategory } from '@activepieces/pieces-framework'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { facebookPagesAuth } from './lib/auth'; import { createPhotoPost } from './lib/actions/create-photo-post'; import { createPost } from './lib/actions/create-post'; import { createVideoPost } from './lib/actions/create-video-post'; - -const markdown = ` -To Obtain a Client ID and Client Secret: - -1. Go to https://developers.facebook.com/ -2. Register for a Facebook Developer account. -3. Once login, click "Make a new app" button. -4. Select "Other" for use cases. -5. Choose "Business" as the type of app. -6. Provide application details: custom name and associated email. -7. Once your application is created, you need to add a new "product". -8. Configure a new product of type "Facebook Login Settings". -9. Default settings should be fine, you only need to provide the Redirect URL in "Valid OAuth Redirect URIs" and your domain name in "Allowed Domains for the JavaScript SDK". -10. Finally, get your application ID and application secret from your app dashboard in Settings > Basic. -`; - -export const facebookPagesAuth = PieceAuth.OAuth2({ - description: markdown, - authUrl: 'https://graph.facebook.com/oauth/authorize', - tokenUrl: 'https://graph.facebook.com/oauth/access_token', - required: true, - scope: [ - 'pages_show_list', - 'pages_manage_posts', - 'business_management', - 'pages_read_engagement', - ], -}); +import { listManagedPagesAction } from './lib/actions/ai/list-managed-pages'; +import { getPageDetailsAction } from './lib/actions/ai/get-page-details'; +import { createPagePostAction } from './lib/actions/ai/create-page-post'; +import { createPagePhotoPostAction } from './lib/actions/ai/create-page-photo-post'; +import { createPageVideoPostAction } from './lib/actions/ai/create-page-video-post'; +import { createMultiPhotoPostAction } from './lib/actions/ai/create-multi-photo-post'; +import { getPagePostsAction } from './lib/actions/ai/get-page-posts'; +import { getPostAction } from './lib/actions/ai/get-post'; +import { updatePostAction } from './lib/actions/ai/update-post'; +import { deletePostAction } from './lib/actions/ai/delete-post'; +import { getScheduledPostsAction } from './lib/actions/ai/get-scheduled-posts'; +import { publishScheduledPostAction } from './lib/actions/ai/publish-scheduled-post'; +import { reschedulePostAction } from './lib/actions/ai/reschedule-post'; +import { getPagePhotosAction } from './lib/actions/ai/get-page-photos'; +import { getPageVideosAction } from './lib/actions/ai/get-page-videos'; +import { getPostReactionsAction } from './lib/actions/ai/get-post-reactions'; export const facebookPages = createPiece({ displayName: 'Facebook Pages', description: 'Manage your Facebook pages to grow your business', - - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/facebook.png', categories: [PieceCategory.MARKETING], authors: ["kishanprmr","MoShizzle","khaledmashaly","abuaboud"], auth: facebookPagesAuth, - actions: [createPost, createPhotoPost, createVideoPost], + actions: [ + createPost, + createPhotoPost, + createVideoPost, + listManagedPagesAction, + getPageDetailsAction, + createPagePostAction, + createPagePhotoPostAction, + createPageVideoPostAction, + createMultiPhotoPostAction, + getPagePostsAction, + getPostAction, + updatePostAction, + deletePostAction, + getScheduledPostsAction, + publishScheduledPostAction, + reschedulePostAction, + getPagePhotosAction, + getPageVideosAction, + getPostReactionsAction, + ], triggers: [], }); + +export { facebookPagesAuth }; diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-multi-photo-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-multi-photo-post.ts new file mode 100644 index 000000000000..b3db7285cedc --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-multi-photo-post.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { createMultiPhotoPostOutputSchema } from '../../output-schemas'; + +export const createMultiPhotoPostAction = createAction({ + auth: facebookPagesAuth, + name: 'create_multi_photo_post', + classification: 'WRITE', + displayName: 'Create Multi-Photo Post', + description: 'Publishes one Facebook Page post containing several photos.', + audience: 'ai', + aiMetadata: { + description: + 'Publish one feed post on a Facebook Page that contains 2 to 20 photos from public image URLs, with an optional message. Uploads each photo unpublished, then creates the post with them attached; if an upload fails no post is created, though earlier unpublished uploads can remain. Use Create Page Photo Post for a single photo. Not idempotent.', + idempotent: false, + }, + outputSchema: createMultiPhotoPostOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + photoUrls: Property.Array({ + displayName: 'Photo URLs', + description: 'Public image URLs that Facebook can download, 2 to 20 of them, in display order.', + required: true, + }), + message: Property.LongText({ + displayName: 'Message', + description: 'Text of the post.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const photoUrls = (propsValue.photoUrls ?? []) + .filter((url): url is string => typeof url === 'string' && url.trim().length > 0) + .map((url) => url.trim()); + if (photoUrls.length < 2 || photoUrls.length > MAX_PHOTOS) { + throw new Error(`Provide between 2 and ${MAX_PHOTOS} photo URLs.`); + } + const accessToken = await facebookPagesCommon.getPageAccessToken({ auth, pageId: propsValue.pageId }); + const pagePath = facebookPagesCommon.objectPath({ id: propsValue.pageId }); + const photos = await Promise.all( + photoUrls.map((url) => + facebookPagesCommon.graphRequest<{ id: string }>({ + accessToken, + method: HttpMethod.POST, + path: `${pagePath}/photos`, + body: { url, published: false }, + }) + ) + ); + const post = await facebookPagesCommon.graphRequest<{ id: string }>({ + accessToken, + method: HttpMethod.POST, + path: `${pagePath}/feed`, + body: { + message: propsValue.message, + attached_media: photos.map((photo) => ({ media_fbid: photo.id })), + }, + }); + return { post_id: post.id, photo_ids: photos.map((photo) => photo.id) }; + }, +}); + +const MAX_PHOTOS = 20; diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-photo-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-photo-post.ts new file mode 100644 index 000000000000..8aefdb055d74 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-photo-post.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphRecord } from '../../common/common'; +import { createPagePhotoPostOutputSchema } from '../../output-schemas'; + +export const createPagePhotoPostAction = createAction({ + auth: facebookPagesAuth, + name: 'create_page_photo_post', + classification: 'WRITE', + displayName: 'Create Page Photo Post', + description: 'Publishes a photo post on a Facebook Page from an image URL.', + audience: 'ai', + aiMetadata: { + description: + 'Publish a single photo to a Facebook Page from a publicly reachable image URL, with an optional caption. Returns both the photo ID and the post_id (PageID_PostID); use post_id for Get Post, Update Post and Delete Post. Use Create Multi-Photo Post for several images in one post. Not idempotent: each call creates a new post.', + idempotent: false, + }, + outputSchema: createPagePhotoPostOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + photoUrl: Property.ShortText({ + displayName: 'Photo URL', + description: 'A public URL of the image that Facebook can download (JPEG, PNG, GIF, BMP or TIFF, up to 4 MB).', + required: true, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'Text shown with the photo.', + required: false, + }), + }, + async run({ auth, propsValue }) { + return facebookPagesCommon.pageRequest({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/photos`, + body: { url: propsValue.photoUrl.trim(), caption: propsValue.caption }, + }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-post.ts new file mode 100644 index 000000000000..bd57546fba0a --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-post.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphRecord } from '../../common/common'; +import { createPagePostOutputSchema } from '../../output-schemas'; + +export const createPagePostAction = createAction({ + auth: facebookPagesAuth, + name: 'create_page_post', + classification: 'WRITE', + displayName: 'Create Page Post', + description: 'Publishes or schedules a text or link post on a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'Publish a text or link post to the feed of a Facebook Page, now or at a scheduled time at least 10 minutes ahead. Returns the post ID in PageID_PostID format for Get Post, Update Post and Delete Post. Use Create Page Photo Post, Create Page Video Post or Create Multi-Photo Post for media. Not idempotent: each call creates a new post.', + idempotent: false, + }, + outputSchema: createPagePostOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + message: Property.LongText({ + displayName: 'Message', + description: 'The text of the post. Required unless a link is given.', + required: false, + }), + link: Property.ShortText({ + displayName: 'Link', + description: 'A URL to attach; Facebook renders it as a preview card.', + required: false, + }), + scheduledPublishTime: Property.DateTime({ + displayName: 'Scheduled Publish Time', + description: 'ISO 8601 date and time to publish the post, at least 10 minutes from now. Leave empty to publish immediately.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const { message, link, scheduledPublishTime } = propsValue; + if (!facebookPagesCommon.isProvided(message) && !facebookPagesCommon.isProvided(link)) { + throw new Error('Provide a message, a link, or both.'); + } + const schedule = facebookPagesCommon.isProvided(scheduledPublishTime) + ? { published: false, scheduled_publish_time: facebookPagesCommon.toScheduledUnixTime({ value: scheduledPublishTime }) } + : {}; + return facebookPagesCommon.pageRequest({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/feed`, + body: { message, link, ...schedule }, + }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-video-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-video-post.ts new file mode 100644 index 000000000000..25e10059a3d3 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/create-page-video-post.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphRecord } from '../../common/common'; +import { createPageVideoPostOutputSchema } from '../../output-schemas'; + +export const createPageVideoPostAction = createAction({ + auth: facebookPagesAuth, + name: 'create_page_video_post', + classification: 'WRITE', + displayName: 'Create Page Video Post', + description: 'Publishes a video on a Facebook Page from a video URL.', + audience: 'ai', + aiMetadata: { + description: + 'Publish a video to a Facebook Page from a publicly reachable video URL (up to 1 GB or 20 minutes), with an optional title and description. Facebook processes the video after the call returns. Returns the video ID only, which is not a post ID; find the resulting post with Get Page Posts. Not idempotent: each call uploads a new video.', + idempotent: false, + }, + outputSchema: createPageVideoPostOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + videoUrl: Property.ShortText({ + displayName: 'Video URL', + description: 'A public URL of the video file that Facebook can download.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Text shown with the video.', + required: false, + }), + }, + async run({ auth, propsValue }) { + return facebookPagesCommon.pageRequest({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/videos`, + body: { file_url: propsValue.videoUrl.trim(), title: propsValue.title, description: propsValue.description }, + }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/delete-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/delete-post.ts new file mode 100644 index 000000000000..678028e787eb --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/delete-post.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { postResultOutputSchema } from '../../output-schemas'; + +export const deletePostAction = createAction({ + auth: facebookPagesAuth, + name: 'delete_post', + classification: 'DESTRUCTIVE', + displayName: 'Delete Post', + description: 'Permanently deletes a Facebook Page post.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete one Facebook Page post by its PageID_PostID, including a scheduled post. This cannot be undone; confirm with the user first.', + idempotent: false, + }, + outputSchema: postResultOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + postId: facebookPagesCommon.postId, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest<{ success: boolean }>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.DELETE, + path: facebookPagesCommon.objectPath({ id: propsValue.postId }), + }); + return { success: response.success, post_id: propsValue.postId.trim() }; + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-details.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-details.ts new file mode 100644 index 000000000000..48712b7d7de3 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-details.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphRecord } from '../../common/common'; +import { pageDetailsOutputSchema } from '../../output-schemas'; + +const PAGE_FIELDS = + 'id,name,username,category,about,description,link,website,phone,emails,single_line_address,followers_count,verification_status,is_published,picture{url},cover{source}'; + +export const getPageDetailsAction = createAction({ + auth: facebookPagesAuth, + name: 'get_page_details', + classification: 'READ', + displayName: 'Get Page Details', + description: 'Gets the profile details of a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'Get the public profile of one Facebook Page the connected account manages: name, username, category, about, description, website, phone, emails, address, follower count, verification status, profile picture and cover photo. Use Fields to request other Graph API Page fields. Read-only.', + idempotent: true, + }, + outputSchema: pageDetailsOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + fields: facebookPagesCommon.fields({ defaultFields: PAGE_FIELDS }), + }, + async run({ auth, propsValue }) { + return facebookPagesCommon.pageRequest({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: facebookPagesCommon.objectPath({ id: propsValue.pageId }), + queryParams: { fields: facebookPagesCommon.fieldsQuery({ fields: propsValue.fields, defaultFields: PAGE_FIELDS }) }, + }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-photos.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-photos.ts new file mode 100644 index 000000000000..99c45b543fd1 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-photos.ts @@ -0,0 +1,39 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphList, GraphRecord } from '../../common/common'; +import { pagePhotosOutputSchema } from '../../output-schemas'; + +export const getPagePhotosAction = createAction({ + auth: facebookPagesAuth, + name: 'get_page_photos', + classification: 'SEARCH', + displayName: 'Get Page Photos', + description: 'Lists the photos uploaded by a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'List the photos a Facebook Page has uploaded, newest first, with each photo ID, caption, created time, link, album and image sizes. Image URLs are temporary CDN links that expire. A photo ID is not a post ID. Paginated with After Cursor. Read-only.', + idempotent: true, + }, + outputSchema: pagePhotosOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + limit: facebookPagesCommon.limit, + after: facebookPagesCommon.after, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/photos`, + queryParams: { + type: 'uploaded', + fields: 'id,name,created_time,updated_time,link,width,height,images,album{id,name}', + ...facebookPagesCommon.cursorQuery({ limit: propsValue.limit, after: propsValue.after }), + }, + }); + return facebookPagesCommon.toCursorPage({ response }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-posts.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-posts.ts new file mode 100644 index 000000000000..09252b6b6820 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-posts.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphList, GraphRecord } from '../../common/common'; +import { pagePostsOutputSchema } from '../../output-schemas'; + +export const getPagePostsAction = createAction({ + auth: facebookPagesAuth, + name: 'get_page_posts', + classification: 'SEARCH', + displayName: 'Get Page Posts', + description: 'Lists the posts published by a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'List the posts a Facebook Page has published, newest first, with each post ID (PageID_PostID), message, created and updated time, permalink and main picture. Does not include scheduled posts (use Get Scheduled Posts) or posts by other people that tag the Page. Paginated with After Cursor. Read-only.', + idempotent: true, + }, + outputSchema: pagePostsOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + limit: facebookPagesCommon.limit, + after: facebookPagesCommon.after, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/posts`, + queryParams: { + fields: 'id,message,created_time,updated_time,permalink_url,status_type,full_picture,is_published', + ...facebookPagesCommon.cursorQuery({ limit: propsValue.limit, after: propsValue.after }), + }, + }); + return facebookPagesCommon.toCursorPage({ response }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-videos.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-videos.ts new file mode 100644 index 000000000000..2c0c88c1a17d --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-page-videos.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphList, GraphRecord } from '../../common/common'; +import { pageVideosOutputSchema } from '../../output-schemas'; + +export const getPageVideosAction = createAction({ + auth: facebookPagesAuth, + name: 'get_page_videos', + classification: 'SEARCH', + displayName: 'Get Page Videos', + description: 'Lists the videos uploaded by a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'List the videos a Facebook Page has uploaded, newest first, with each video ID, title, description, created time, length in seconds, permalink and thumbnail. A video ID is not a post ID. Paginated with After Cursor. Read-only.', + idempotent: true, + }, + outputSchema: pageVideosOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + limit: facebookPagesCommon.limit, + after: facebookPagesCommon.after, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/videos`, + queryParams: { + fields: 'id,title,description,created_time,updated_time,length,permalink_url,picture', + ...facebookPagesCommon.cursorQuery({ limit: propsValue.limit, after: propsValue.after }), + }, + }); + return facebookPagesCommon.toCursorPage({ response }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post-reactions.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post-reactions.ts new file mode 100644 index 000000000000..627886a0a38c --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post-reactions.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { postReactionsOutputSchema } from '../../output-schemas'; + +const REACTION_TYPES = ['LIKE', 'LOVE', 'CARE', 'HAHA', 'WOW', 'SAD', 'ANGRY']; + +export const getPostReactionsAction = createAction({ + auth: facebookPagesAuth, + name: 'get_post_reactions', + classification: 'READ', + displayName: 'Get Post Reactions', + description: 'Counts the reactions on a Facebook Page post.', + audience: 'ai', + aiMetadata: { + description: + 'Count the reactions on a Facebook Page post, either all reactions or only one type such as LOVE or HAHA. Returns the total count only, not the people who reacted. Very new posts can report partial counts. Read-only.', + idempotent: true, + }, + outputSchema: postReactionsOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + objectId: Property.ShortText({ + displayName: 'Post ID', + description: 'A post ID in PageID_PostID format.', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Reaction Type', + description: 'Only count this reaction type. Leave empty to count all reactions.', + required: false, + options: { + options: REACTION_TYPES.map((type) => ({ label: type, value: type })), + }, + }), + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest<{ summary?: { total_count?: number } }>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: `${facebookPagesCommon.objectPath({ id: propsValue.objectId })}/reactions`, + queryParams: { summary: 'total_count', limit: '0', type: propsValue.type }, + }); + return { + object_id: propsValue.objectId.trim(), + type: propsValue.type ?? 'ALL', + total_count: response.summary?.total_count ?? 0, + }; + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post.ts new file mode 100644 index 000000000000..aca87ab6d183 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-post.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphRecord } from '../../common/common'; +import { postOutputSchema } from '../../output-schemas'; + +const POST_FIELDS = + 'id,message,created_time,updated_time,permalink_url,status_type,full_picture,is_published,scheduled_publish_time,attachments{media_type,type,title,description,url,media}'; + +export const getPostAction = createAction({ + auth: facebookPagesAuth, + name: 'get_post', + classification: 'READ', + displayName: 'Get Post', + description: 'Gets one post from a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Facebook Page post by its PageID_PostID: message, created and updated time, permalink, main picture, publish status, scheduled time and attachments. Use Get Post Reactions for reaction counts. Use Fields to request other Graph API post fields. Read-only.', + idempotent: true, + }, + outputSchema: postOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + postId: facebookPagesCommon.postId, + fields: facebookPagesCommon.fields({ defaultFields: POST_FIELDS }), + }, + async run({ auth, propsValue }) { + return facebookPagesCommon.pageRequest({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: facebookPagesCommon.objectPath({ id: propsValue.postId }), + queryParams: { fields: facebookPagesCommon.fieldsQuery({ fields: propsValue.fields, defaultFields: POST_FIELDS }) }, + }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-scheduled-posts.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-scheduled-posts.ts new file mode 100644 index 000000000000..db52253a1226 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/get-scheduled-posts.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphList, GraphRecord } from '../../common/common'; +import { scheduledPostsOutputSchema } from '../../output-schemas'; + +export const getScheduledPostsAction = createAction({ + auth: facebookPagesAuth, + name: 'get_scheduled_posts', + classification: 'SEARCH', + displayName: 'Get Scheduled Posts', + description: 'Lists the scheduled, not yet published posts of a Facebook Page.', + audience: 'ai', + aiMetadata: { + description: + 'List the posts on a Facebook Page that are scheduled but not yet published, with each post ID, message, created time and scheduled publish time. Use the IDs with Publish Scheduled Post, Reschedule Post or Delete Post. Paginated with After Cursor. Read-only.', + idempotent: true, + }, + outputSchema: scheduledPostsOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + limit: facebookPagesCommon.limit, + after: facebookPagesCommon.after, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.GET, + path: `${facebookPagesCommon.objectPath({ id: propsValue.pageId })}/scheduled_posts`, + queryParams: { + fields: 'id,message,created_time,scheduled_publish_time,permalink_url,is_published,full_picture', + ...facebookPagesCommon.cursorQuery({ limit: propsValue.limit, after: propsValue.after }), + }, + }); + return facebookPagesCommon.toCursorPage({ response }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/list-managed-pages.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/list-managed-pages.ts new file mode 100644 index 000000000000..02f5da04bc37 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/list-managed-pages.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon, GraphList, GraphRecord } from '../../common/common'; +import { listManagedPagesOutputSchema } from '../../output-schemas'; + +export const listManagedPagesAction = createAction({ + auth: facebookPagesAuth, + name: 'list_managed_pages', + classification: 'SEARCH', + displayName: 'List Managed Pages', + description: 'Lists the Facebook Pages the connected account manages.', + audience: 'ai', + aiMetadata: { + description: + 'List the Facebook Pages the connected account has a role on, with each Page ID, name, category, username, link and the tasks the account can perform on it. The starting point for every other Facebook Pages action, which all need a Page ID. Paginated with After Cursor. Read-only.', + idempotent: true, + }, + outputSchema: listManagedPagesOutputSchema, + props: { + limit: facebookPagesCommon.limit, + after: facebookPagesCommon.after, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.graphRequest>({ + accessToken: auth.access_token, + method: HttpMethod.GET, + path: 'me/accounts', + queryParams: { + fields: 'id,name,category,username,link,tasks', + ...facebookPagesCommon.cursorQuery({ limit: propsValue.limit, after: propsValue.after }), + }, + }); + return facebookPagesCommon.toCursorPage({ response }); + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/publish-scheduled-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/publish-scheduled-post.ts new file mode 100644 index 000000000000..8a1b269f0ff3 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/publish-scheduled-post.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { postResultOutputSchema } from '../../output-schemas'; + +export const publishScheduledPostAction = createAction({ + auth: facebookPagesAuth, + name: 'publish_scheduled_post', + classification: 'WRITE', + displayName: 'Publish Scheduled Post', + description: 'Publishes a scheduled Facebook Page post immediately.', + audience: 'ai', + aiMetadata: { + description: + 'Publish a scheduled or unpublished Facebook Page post right away instead of waiting for its scheduled time. Find scheduled posts with Get Scheduled Posts. Facebook only allows this on posts created through this same app.', + idempotent: true, + }, + outputSchema: postResultOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + postId: facebookPagesCommon.postId, + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest<{ success: boolean }>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: facebookPagesCommon.objectPath({ id: propsValue.postId }), + body: { is_published: true }, + }); + return { success: response.success, post_id: propsValue.postId.trim() }; + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/reschedule-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/reschedule-post.ts new file mode 100644 index 000000000000..31b06bfaf755 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/reschedule-post.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { reschedulePostOutputSchema } from '../../output-schemas'; + +export const reschedulePostAction = createAction({ + auth: facebookPagesAuth, + name: 'reschedule_post', + classification: 'WRITE', + displayName: 'Reschedule Post', + description: 'Changes when a scheduled Facebook Page post will be published.', + audience: 'ai', + aiMetadata: { + description: + 'Move a scheduled, not yet published Facebook Page post to a new publish time at least 10 minutes ahead. Find scheduled posts with Get Scheduled Posts; use Publish Scheduled Post to publish now instead. Facebook only allows this on posts created through this same app.', + idempotent: true, + }, + outputSchema: reschedulePostOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + postId: facebookPagesCommon.postId, + scheduledPublishTime: Property.DateTime({ + displayName: 'Scheduled Publish Time', + description: 'ISO 8601 date and time to publish the post, at least 10 minutes from now.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const scheduledPublishTime = facebookPagesCommon.toScheduledUnixTime({ value: propsValue.scheduledPublishTime }); + const response = await facebookPagesCommon.pageRequest<{ success: boolean }>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: facebookPagesCommon.objectPath({ id: propsValue.postId }), + body: { scheduled_publish_time: scheduledPublishTime }, + }); + return { + success: response.success, + post_id: propsValue.postId.trim(), + scheduled_publish_time: new Date(scheduledPublishTime * 1000).toISOString(), + }; + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/ai/update-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/ai/update-post.ts new file mode 100644 index 000000000000..73de53c52de3 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/actions/ai/update-post.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../../auth'; +import { facebookPagesCommon } from '../../common/common'; +import { postResultOutputSchema } from '../../output-schemas'; + +export const updatePostAction = createAction({ + auth: facebookPagesAuth, + name: 'update_post', + classification: 'WRITE', + displayName: 'Update Post', + description: 'Changes the message of a Facebook Page post.', + audience: 'ai', + aiMetadata: { + description: + 'Replace the message text of an existing Facebook Page post. Facebook only allows this on posts created through this same app, such as with Create Page Post; posts made on facebook.com or by other tools cannot be edited here. Photos, videos and links on the post cannot be changed.', + idempotent: true, + }, + outputSchema: postResultOutputSchema, + props: { + pageId: facebookPagesCommon.pageId, + postId: facebookPagesCommon.postId, + message: Property.LongText({ + displayName: 'Message', + description: 'The new text of the post.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await facebookPagesCommon.pageRequest<{ success: boolean }>({ + auth, + pageId: propsValue.pageId, + method: HttpMethod.POST, + path: facebookPagesCommon.objectPath({ id: propsValue.postId }), + body: { message: propsValue.message }, + }); + return { success: response.success, post_id: propsValue.postId.trim() }; + }, +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts index 751c49fa325b..75b70bccbcda 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts @@ -1,17 +1,16 @@ import { createAction } from '@activepieces/pieces-framework'; - -import { facebookPagesCommon, FacebookPageDropdown } from '../common/common'; -import { facebookPagesAuth } from '../..'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../auth'; +import { facebookPagesCommon } from '../common/common'; import { createPhotoPostActionOutputSchema } from '../output-schemas'; export const createPhotoPost = createAction({ auth: facebookPagesAuth, - name: 'create_photo_post', classification: 'WRITE', displayName: 'Create Page Photo', description: 'Create a photo on a Facebook Page you manage', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a photo post to a Facebook Page the connected account manages by uploading an image from a publicly reachable URL, with an optional caption. Choose this when the post is an image rather than plain text or video. Requires a managed page and a photo URL that Facebook can fetch; not idempotent, as each call creates a new photo post.', idempotent: false }, outputSchema: createPhotoPostActionOutputSchema, props: { @@ -19,15 +18,12 @@ export const createPhotoPost = createAction({ photo: facebookPagesCommon.photo, caption: facebookPagesCommon.caption, }, - async run(context) { - const page: FacebookPageDropdown = context.propsValue.page!; - - const result = await facebookPagesCommon.createPhotoPost( - page, - context.propsValue.caption, - context.propsValue.photo - ); - - return result; + async run({ propsValue }) { + return facebookPagesCommon.graphRequest({ + accessToken: propsValue.page.accessToken, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.page.id })}/photos`, + body: { url: propsValue.photo, caption: propsValue.caption }, + }); }, }); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts index 6d3c36aafeed..ce814a5534fc 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts @@ -1,6 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; -import { facebookPagesCommon, FacebookPageDropdown } from '../common/common'; -import { facebookPagesAuth } from '../..'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../auth'; +import { facebookPagesCommon } from '../common/common'; import { createPostActionOutputSchema } from '../output-schemas'; export const createPost = createAction({ @@ -9,7 +10,7 @@ export const createPost = createAction({ classification: 'WRITE', displayName: 'Create Page Post', description: 'Create a post on a Facebook Page you manage', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a text status update to the feed of a Facebook Page the connected account manages, optionally attaching a link that Facebook will render as a preview card. Choose this for plain-text or link announcements; use the photo or video post actions for media uploads. Requires selecting a managed page (which supplies the page-scoped access token) and a message; not idempotent, as each call publishes a separate post.', idempotent: false }, outputSchema: createPostActionOutputSchema, props: { @@ -17,15 +18,12 @@ export const createPost = createAction({ message: facebookPagesCommon.message, link: facebookPagesCommon.link, }, - async run(context) { - const page: FacebookPageDropdown = context.propsValue.page!; - - const result = await facebookPagesCommon.createPost( - page, - context.propsValue.message, - context.propsValue.link - ); - - return result; + async run({ propsValue }) { + return facebookPagesCommon.graphRequest({ + accessToken: propsValue.page.accessToken, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.page.id })}/feed`, + body: { message: propsValue.message, link: propsValue.link }, + }); }, }); diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts index be734c15decd..3342b38e1626 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts @@ -1,6 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; -import { FacebookPageDropdown, facebookPagesCommon } from '../common/common'; -import { facebookPagesAuth } from '../..'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { facebookPagesAuth } from '../auth'; +import { facebookPagesCommon } from '../common/common'; import { createVideoPostActionOutputSchema } from '../output-schemas'; export const createVideoPost = createAction({ @@ -9,7 +10,7 @@ export const createVideoPost = createAction({ classification: 'WRITE', displayName: 'Create Page Video', description: 'Create a video on a Facebook Page you manage', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a video post to a Facebook Page the connected account manages by uploading a video from a publicly reachable URL, with an optional title and description. Choose this for video content rather than text or photo posts. Requires a managed page and a video URL Facebook can fetch (limit 1GB or 20 minutes); not idempotent, as each call uploads a new video.', idempotent: false }, outputSchema: createVideoPostActionOutputSchema, props: { @@ -18,16 +19,12 @@ export const createVideoPost = createAction({ title: facebookPagesCommon.title, description: facebookPagesCommon.description, }, - async run(context) { - const page: FacebookPageDropdown = context.propsValue.page!; - - const result = await facebookPagesCommon.createVideoPost( - page, - context.propsValue.title, - context.propsValue.description, - context.propsValue.video - ); - - return result; + async run({ propsValue }) { + return facebookPagesCommon.graphRequest({ + accessToken: propsValue.page.accessToken, + method: HttpMethod.POST, + path: `${facebookPagesCommon.objectPath({ id: propsValue.page.id })}/videos`, + body: { title: propsValue.title, description: propsValue.description, file_url: propsValue.video }, + }); }, }); diff --git a/packages/pieces/community/facebook-pages/src/lib/auth.ts b/packages/pieces/community/facebook-pages/src/lib/auth.ts new file mode 100644 index 000000000000..70ecf98d1779 --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/auth.ts @@ -0,0 +1,29 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; + +const markdown = ` +To Obtain a Client ID and Client Secret: + +1. Go to https://developers.facebook.com/ +2. Register for a Facebook Developer account. +3. Once login, click "Make a new app" button. +4. Select "Other" for use cases. +5. Choose "Business" as the type of app. +6. Provide application details: custom name and associated email. +7. Once your application is created, you need to add a new "product". +8. Configure a new product of type "Facebook Login Settings". +9. Default settings should be fine, you only need to provide the Redirect URL in "Valid OAuth Redirect URIs" and your domain name in "Allowed Domains for the JavaScript SDK". +10. Finally, get your application ID and application secret from your app dashboard in Settings > Basic. +`; + +export const facebookPagesAuth = PieceAuth.OAuth2({ + description: markdown, + authUrl: 'https://graph.facebook.com/oauth/authorize', + tokenUrl: 'https://graph.facebook.com/oauth/access_token', + required: true, + scope: [ + 'pages_show_list', + 'pages_manage_posts', + 'business_management', + 'pages_read_engagement', + ], +}); diff --git a/packages/pieces/community/facebook-pages/src/lib/common/common.ts b/packages/pieces/community/facebook-pages/src/lib/common/common.ts index 89f2bdb6c9c0..796a7586cbbd 100644 --- a/packages/pieces/community/facebook-pages/src/lib/common/common.ts +++ b/packages/pieces/community/facebook-pages/src/lib/common/common.ts @@ -1,58 +1,130 @@ -import { - HttpMethod, - httpClient, - getAccessTokenOrThrow, -} from '@activepieces/pieces-common'; +import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common'; import { OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; -import { facebookPagesAuth } from '../..'; +import { facebookPagesAuth } from '../auth'; + +async function graphRequest({ accessToken, method, path, queryParams, body }: GraphRequest): Promise { + const response = await httpClient.sendRequest({ + method, + url: `${GRAPH_API_URL}/${path}`, + queryParams: compactQuery({ query: queryParams ?? {} }), + body, + authentication: { type: AuthenticationType.BEARER_TOKEN, token: accessToken }, + }); + return response.body; +} + +async function pageRequest({ auth, pageId, ...request }: PageRequest): Promise { + const accessToken = await getPageAccessToken({ auth, pageId }); + return graphRequest({ accessToken, ...request }); +} + +async function getPageAccessToken({ auth, pageId }: { auth: OAuth2PropertyValue; pageId: string }): Promise { + const page = await graphRequest<{ access_token?: string }>({ + accessToken: auth.access_token, + method: HttpMethod.GET, + path: objectPath({ id: pageId }), + queryParams: { fields: 'access_token' }, + }); + if (!page.access_token) { + throw new Error( + `No Page access token was returned for Page ${pageId}. The connected account needs a role on this Page; List Managed Pages shows the Pages it can use.` + ); + } + return page.access_token; +} + +async function listAllPages({ accessToken }: { accessToken: string }): Promise { + const pages: FacebookPage[] = []; + let after: string | undefined; + do { + const response = await graphRequest>({ + accessToken, + method: HttpMethod.GET, + path: 'me/accounts', + queryParams: { fields: 'id,name,access_token', limit: '100', after }, + }); + pages.push(...(response.data ?? [])); + after = response.paging?.next ? response.paging.cursors?.after : undefined; + } while (after); + return pages; +} + +function objectPath({ id }: { id: string }): string { + return encodeURIComponent(id.trim()); +} + +function toCursorPage({ response }: { response: GraphList }) { + const items = response.data ?? []; + return { + items, + count: items.length, + next_cursor: response.paging?.next ? response.paging.cursors?.after ?? null : null, + }; +} + +function cursorQuery({ limit, after }: { limit?: number; after?: string }): Record { + return { + limit: String(Math.min(MAX_PAGE_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_PAGE_LIMIT)))), + after: isProvided(after) ? after.trim() : undefined, + }; +} + +function fieldsQuery({ fields, defaultFields }: { fields: unknown; defaultFields: string }): string { + const requested = Array.isArray(fields) + ? fields.filter((field): field is string => typeof field === 'string' && field.trim().length > 0).map((field) => field.trim()) + : []; + return requested.length > 0 ? requested.join(',') : defaultFields; +} + +function toScheduledUnixTime({ value }: { value: string }): number { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + throw new Error('Scheduled Publish Time must be a valid ISO 8601 date and time.'); + } + if (timestamp < Date.now() + MIN_SCHEDULE_LEAD_MS) { + throw new Error('Scheduled Publish Time must be at least 10 minutes in the future.'); + } + return Math.floor(timestamp / 1000); +} + +function isProvided(value: string | undefined | null): value is string { + return value !== undefined && value !== null && value.trim().length > 0; +} + +function compactQuery({ query }: { query: Record }): Record { + return Object.fromEntries( + Object.entries(query).filter((entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== '') + ); +} + +const GRAPH_API_URL = 'https://graph.facebook.com/v23.0'; +const DEFAULT_PAGE_LIMIT = 25; +const MAX_PAGE_LIMIT = 100; +const MIN_SCHEDULE_LEAD_MS = 10 * 60 * 1000; export const facebookPagesCommon = { - baseUrl: 'https://graph.facebook.com/v17.0', - page: Property.Dropdown({ + page: Property.Dropdown({ auth: facebookPagesAuth, displayName: 'Page', required: true, refreshers: [], options: async ({ auth }) => { if (!auth) { - return { - disabled: true, - options: [], - placeholder: 'Connect your account', - }; + return { disabled: true, options: [], placeholder: 'Connect your account' }; } - - try { - const accessToken: string = getAccessTokenOrThrow( - auth - ); - const pages: any[] = ( - await facebookPagesCommon.getPages(accessToken) - ).map((page: FacebookPage) => { - return { - label: page.name, - value: { - id: page.id, - accessToken: page.access_token, - }, - }; - }); - - return { - options: pages, - placeholder: 'Choose a page', - }; - } catch (e) { - console.debug(e); - return { - disabled: true, - options: [], - placeholder: 'Connect your account', - }; + const pages = await listAllPages({ accessToken: auth.access_token }).catch(() => null); + if (pages === null) { + return { disabled: true, options: [], placeholder: 'Could not load your Pages, reconnect your account' }; } + return { + options: pages.map((page) => ({ + label: page.name, + value: { id: page.id, accessToken: page.access_token }, + })), + placeholder: 'Choose a page', + }; }, }), - message: Property.LongText({ displayName: 'Message', required: true, @@ -61,7 +133,6 @@ export const facebookPagesCommon = { displayName: 'Link', required: false, }), - caption: Property.LongText({ displayName: 'Caption', required: false, @@ -71,7 +142,6 @@ export const facebookPagesCommon = { description: 'A URL we can access for the photo', required: true, }), - title: Property.ShortText({ displayName: 'Title', required: false, @@ -85,81 +155,79 @@ export const facebookPagesCommon = { description: 'A URL we can access for the video (Limit: 1GB or 20 minutes)', required: true, }), + pageId: Property.ShortText({ + displayName: 'Page ID', + description: 'Numeric ID of a Facebook Page the connected account manages. Find it with List Managed Pages.', + required: true, + }), + postId: Property.ShortText({ + displayName: 'Post ID', + description: + 'The post ID in PageID_PostID format, as returned in post_id by Create Page Photo Post, in id by Create Page Post, or by Get Page Posts. A photo or video ID is not a post ID.', + required: true, + }), + limit: Property.Number({ + displayName: 'Limit', + description: `Maximum number of items to return in this page (1 to ${MAX_PAGE_LIMIT}).`, + required: false, + defaultValue: DEFAULT_PAGE_LIMIT, + min: 1, + max: MAX_PAGE_LIMIT, + step: 1, + }), + after: Property.ShortText({ + displayName: 'After Cursor', + description: 'The next_cursor value from a previous call, to fetch the next page.', + required: false, + }), + fields: ({ defaultFields }: { defaultFields: string }) => + Property.Array({ + displayName: 'Fields', + description: `Graph API fields to return instead of the defaults (${defaultFields}).`, + required: false, + }), + graphRequest, + pageRequest, + getPageAccessToken, + objectPath, + toCursorPage, + cursorQuery, + fieldsQuery, + toScheduledUnixTime, + isProvided, +}; - getPages: async (accessToken: string) => { - const response = await httpClient.sendRequest({ - method: HttpMethod.GET, - url: `${facebookPagesCommon.baseUrl}/me/accounts?access_token=${accessToken}`, - }); - - return response.body.data; - }, - - createPost: async ( - page: FacebookPageDropdown, - message: string, - link: string | undefined - ) => { - const response = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${facebookPagesCommon.baseUrl}/${page.id}/feed`, - body: { - access_token: page.accessToken, - message: message, - link: link, - }, - }); - return response.body; - }, - createPhotoPost: async ( - page: FacebookPageDropdown, - caption: string | undefined, - photo: string - ) => { - const response = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${facebookPagesCommon.baseUrl}/${page.id}/photos`, - body: { - access_token: page.accessToken, - url: photo, - caption: caption, - }, - }); - return response.body; - }, - - createVideoPost: async ( - page: FacebookPageDropdown, - title: string | undefined, - description: string | undefined, - video: string - ) => { - const response = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${facebookPagesCommon.baseUrl}/${page.id}/videos`, - body: { - access_token: page.accessToken, - title: title, - description: description, - file_url: video, - }, - }); +type GraphRequest = { + accessToken: string; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: Record; +}; - return response.body; - }, +type PageRequest = Omit & { + auth: OAuth2PropertyValue; + pageId: string; }; -export interface FacebookPage { +type FacebookPage = { id: string; name: string; - category: string; - category_list: string[]; access_token: string; - tasks: string[]; -} +}; + +export type GraphList = { + data?: T[]; + paging?: { + cursors?: { before?: string; after?: string }; + next?: string; + }; +}; -export interface FacebookPageDropdown { +export type GraphRecord = Record; + +export type FacebookPageDropdown = { id: string; accessToken: string; -} +}; diff --git a/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts b/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts index 6a78c3433c3e..385b19c8849f 100644 --- a/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts +++ b/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts @@ -1,4 +1,4 @@ -import { OutputSchema } from '@activepieces/pieces-framework'; +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; export const createPostActionOutputSchema: OutputSchema = { fields: [{ key: 'id', label: 'Post ID' }], @@ -14,3 +14,204 @@ export const createPhotoPostActionOutputSchema: OutputSchema = { export const createVideoPostActionOutputSchema: OutputSchema = { fields: [{ key: 'id', label: 'Video ID' }], }; + +const pageMetaFields: OutputSchemaField[] = [ + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_cursor', label: 'Next Cursor', description: 'Pass as After Cursor to fetch the next page. Empty on the last page.' }, +]; + +const postResultFields: OutputSchemaField[] = [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'post_id', label: 'Post ID' }, +]; + +const postFields: OutputSchemaField[] = [ + { key: 'id', label: 'Post ID' }, + { key: 'message', label: 'Message' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'updated_time', label: 'Updated Time', format: 'datetime' }, + { key: 'permalink_url', label: 'Permalink', format: 'url' }, + { key: 'status_type', label: 'Post Type' }, + { key: 'full_picture', label: 'Picture', format: 'image' }, + { key: 'is_published', label: 'Published', format: 'boolean' }, +]; + +export const listManagedPagesOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Pages', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Page ID' }, + { key: 'name', label: 'Name' }, + { key: 'category', label: 'Category' }, + { key: 'username', label: 'Username' }, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'tasks', label: 'Tasks' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const pageDetailsOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Page ID' }, + { key: 'name', label: 'Name' }, + { key: 'username', label: 'Username' }, + { key: 'category', label: 'Category' }, + { key: 'about', label: 'About' }, + { key: 'description', label: 'Description' }, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'website', label: 'Website', format: 'url' }, + { key: 'phone', label: 'Phone' }, + { key: 'emails', label: 'Emails' }, + { key: 'single_line_address', label: 'Address' }, + { key: 'followers_count', label: 'Followers', format: 'number' }, + { key: 'verification_status', label: 'Verification Status' }, + { key: 'is_published', label: 'Published', format: 'boolean' }, + { key: 'picture_url', label: 'Profile Picture', value: 'picture.data.url', format: 'image' }, + { key: 'cover_url', label: 'Cover Photo', value: 'cover.source', format: 'image' }, + ], +}; + +export const createPagePostOutputSchema: OutputSchema = { + fields: [{ key: 'id', label: 'Post ID' }], +}; + +export const createPagePhotoPostOutputSchema: OutputSchema = { + fields: [ + { key: 'post_id', label: 'Post ID' }, + { key: 'id', label: 'Photo ID' }, + ], +}; + +export const createPageVideoPostOutputSchema: OutputSchema = { + fields: [{ key: 'id', label: 'Video ID' }], +}; + +export const createMultiPhotoPostOutputSchema: OutputSchema = { + fields: [ + { key: 'post_id', label: 'Post ID' }, + { key: 'photo_ids', label: 'Photo IDs' }, + ], +}; + +export const pagePostsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Posts', labelKey: 'message', listItems: postFields }, ...pageMetaFields], +}; + +export const postOutputSchema: OutputSchema = { + fields: [ + ...postFields, + { key: 'scheduled_publish_time', label: 'Scheduled Publish Time (Unix seconds)', format: 'number' }, + { + key: 'attachments', + label: 'Attachments', + value: 'attachments.data', + labelKey: 'title', + listItems: [ + { key: 'type', label: 'Type' }, + { key: 'media_type', label: 'Media Type' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'image', label: 'Image', value: 'media.image.src', format: 'image' }, + ], + }, + ], +}; + +export const postResultOutputSchema: OutputSchema = { + fields: postResultFields, +}; + +export const reschedulePostOutputSchema: OutputSchema = { + fields: [...postResultFields, { key: 'scheduled_publish_time', label: 'Scheduled Publish Time', format: 'datetime' }], +}; + +export const scheduledPostsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Scheduled Posts', + labelKey: 'message', + listItems: [ + { key: 'id', label: 'Post ID' }, + { key: 'message', label: 'Message' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'scheduled_publish_time', label: 'Scheduled Publish Time (Unix seconds)', format: 'number' }, + { key: 'permalink_url', label: 'Permalink', format: 'url' }, + { key: 'is_published', label: 'Published', format: 'boolean' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const pagePhotosOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Photos', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Photo ID' }, + { key: 'name', label: 'Caption' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'updated_time', label: 'Updated Time', format: 'datetime' }, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { + key: 'images', + label: 'Image Sizes', + listItems: [ + { key: 'source', label: 'Image', format: 'image' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + ], + }, + { + key: 'album', + label: 'Album', + children: [ + { key: 'id', label: 'Album ID' }, + { key: 'name', label: 'Album Name' }, + ], + }, + ], + }, + ...pageMetaFields, + ], +}; + +export const pageVideosOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Videos', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'Video ID' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'updated_time', label: 'Updated Time', format: 'datetime' }, + { key: 'length', label: 'Length (seconds)', format: 'number' }, + { key: 'permalink_url', label: 'Permalink Path' }, + { key: 'picture', label: 'Thumbnail', format: 'image' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const postReactionsOutputSchema: OutputSchema = { + fields: [ + { key: 'object_id', label: 'Post ID' }, + { key: 'type', label: 'Reaction Type' }, + { key: 'total_count', label: 'Total Reactions', format: 'number' }, + ], +}; diff --git a/packages/pieces/community/jira-cloud/package.json b/packages/pieces/community/jira-cloud/package.json index 23974013d6cf..21d9e9f47855 100644 --- a/packages/pieces/community/jira-cloud/package.json +++ b/packages/pieces/community/jira-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-jira-cloud", - "version": "0.4.0", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/jira-cloud/src/index.ts b/packages/pieces/community/jira-cloud/src/index.ts index 136cb4b818d1..8377d38015b0 100644 --- a/packages/pieces/community/jira-cloud/src/index.ts +++ b/packages/pieces/community/jira-cloud/src/index.ts @@ -28,13 +28,73 @@ import { newAttachment } from './lib/triggers/new-attachment'; import { newIssueType } from './lib/triggers/new-issue-type'; import { newProject } from './lib/triggers/new-project'; import { newPriority } from './lib/triggers/new-priority'; +import { addCommentAiAction } from './lib/actions/ai/add-comment'; +import { addWatcherAiAction } from './lib/actions/ai/add-watcher'; +import { addWorklogAiAction } from './lib/actions/ai/add-worklog'; +import { assignIssueToUserAiAction } from './lib/actions/ai/assign-issue-to-user'; +import { bulkCreateIssuesAiAction } from './lib/actions/ai/bulk-create-issues'; +import { bulkGetIssuesAiAction } from './lib/actions/ai/bulk-get-issues'; +import { countIssuesAiAction } from './lib/actions/ai/count-issues'; +import { createBoardAiAction } from './lib/actions/ai/create-board'; +import { createIssueLinkAiAction } from './lib/actions/ai/create-issue-link'; +import { createIssueWithFieldsAiAction } from './lib/actions/ai/create-issue-with-fields'; +import { createSprintAiAction } from './lib/actions/ai/create-sprint'; +import { deleteAttachmentAiAction } from './lib/actions/ai/delete-attachment'; +import { deleteCommentAiAction } from './lib/actions/ai/delete-comment'; +import { deleteIssueAiAction } from './lib/actions/ai/delete-issue'; +import { deleteWorklogAiAction } from './lib/actions/ai/delete-worklog'; +import { downloadAttachmentAiAction } from './lib/actions/ai/download-attachment'; +import { editIssueAiAction } from './lib/actions/ai/edit-issue'; +import { fetchIssueAiAction } from './lib/actions/ai/fetch-issue'; +import { findUsersAiAction } from './lib/actions/ai/find-users'; +import { getCommentAiAction } from './lib/actions/ai/get-comment'; +import { getCurrentUserAiAction } from './lib/actions/ai/get-current-user'; +import { getFavoriteFiltersAiAction } from './lib/actions/ai/get-favorite-filters'; +import { getFieldsAiAction } from './lib/actions/ai/get-fields'; +import { getFilterAiAction } from './lib/actions/ai/get-filter'; +import { getIssueEditMetadataAiAction } from './lib/actions/ai/get-issue-edit-metadata'; +import { getIssueLinkTypesAiAction } from './lib/actions/ai/get-issue-link-types'; +import { getIssueResolutionsAiAction } from './lib/actions/ai/get-issue-resolutions'; +import { getIssueTransitionsAiAction } from './lib/actions/ai/get-issue-transitions'; +import { getIssueTypeCreateFieldsAiAction } from './lib/actions/ai/get-issue-type-create-fields'; +import { getIssueTypesAiAction } from './lib/actions/ai/get-issue-types'; +import { getIssueVotesAiAction } from './lib/actions/ai/get-issue-votes'; +import { getIssueWatchersAiAction } from './lib/actions/ai/get-issue-watchers'; +import { getJqlFieldSuggestionsAiAction } from './lib/actions/ai/get-jql-field-suggestions'; +import { getJqlReferenceDataAiAction } from './lib/actions/ai/get-jql-reference-data'; +import { getMyPermissionsAiAction } from './lib/actions/ai/get-my-permissions'; +import { getPermittedProjectsAiAction } from './lib/actions/ai/get-permitted-projects'; +import { getPrioritiesAiAction } from './lib/actions/ai/get-priorities'; +import { getProjectComponentsAiAction } from './lib/actions/ai/get-project-components'; +import { getProjectVersionsAiAction } from './lib/actions/ai/get-project-versions'; +import { getProjectAiAction } from './lib/actions/ai/get-project'; +import { getRecentProjectsAiAction } from './lib/actions/ai/get-recent-projects'; +import { getRemoteIssueLinksAiAction } from './lib/actions/ai/get-remote-issue-links'; +import { getSprintAiAction } from './lib/actions/ai/get-sprint'; +import { getStatusesAiAction } from './lib/actions/ai/get-statuses'; +import { getUserGroupsAiAction } from './lib/actions/ai/get-user-groups'; +import { getWorklogsAiAction } from './lib/actions/ai/get-worklogs'; +import { listBoardsAiAction } from './lib/actions/ai/list-boards'; +import { listCommentsAiAction } from './lib/actions/ai/list-comments'; +import { listProjectsAiAction } from './lib/actions/ai/list-projects'; +import { listSprintsAiAction } from './lib/actions/ai/list-sprints'; +import { moveIssuesToSprintAiAction } from './lib/actions/ai/move-issues-to-sprint'; +import { parseJqlAiAction } from './lib/actions/ai/parse-jql'; +import { removeWatcherAiAction } from './lib/actions/ai/remove-watcher'; +import { searchDashboardsAiAction } from './lib/actions/ai/search-dashboards'; +import { searchFiltersAiAction } from './lib/actions/ai/search-filters'; +import { searchIssuesByJqlAiAction } from './lib/actions/ai/search-issues-by-jql'; +import { sendIssueNotificationAiAction } from './lib/actions/ai/send-issue-notification'; +import { transitionIssueStatusAiAction } from './lib/actions/ai/transition-issue-status'; +import { updateCommentAiAction } from './lib/actions/ai/update-comment'; +import { uploadAttachmentAiAction } from './lib/actions/ai/upload-attachment'; export const jiraCloud = createPiece({ displayName: 'Jira Cloud', description: 'Issue tracking and project management', auth: jiraCloudAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/jira.png', categories: [PieceCategory.PRODUCTIVITY], authors: ['kishanprmr', 'MoShizzle', 'abuaboud', 'prasanna2000-max', 'sanket-a11y'], @@ -55,6 +115,66 @@ export const jiraCloud = createPiece({ markdownToJiraFormat, getIssueAction, transitionIssueAction, + addCommentAiAction, + addWatcherAiAction, + addWorklogAiAction, + assignIssueToUserAiAction, + bulkCreateIssuesAiAction, + bulkGetIssuesAiAction, + countIssuesAiAction, + createBoardAiAction, + createIssueLinkAiAction, + createIssueWithFieldsAiAction, + createSprintAiAction, + deleteAttachmentAiAction, + deleteCommentAiAction, + deleteIssueAiAction, + deleteWorklogAiAction, + downloadAttachmentAiAction, + editIssueAiAction, + fetchIssueAiAction, + findUsersAiAction, + getCommentAiAction, + getCurrentUserAiAction, + getFavoriteFiltersAiAction, + getFieldsAiAction, + getFilterAiAction, + getIssueEditMetadataAiAction, + getIssueLinkTypesAiAction, + getIssueResolutionsAiAction, + getIssueTransitionsAiAction, + getIssueTypeCreateFieldsAiAction, + getIssueTypesAiAction, + getIssueVotesAiAction, + getIssueWatchersAiAction, + getJqlFieldSuggestionsAiAction, + getJqlReferenceDataAiAction, + getMyPermissionsAiAction, + getPermittedProjectsAiAction, + getPrioritiesAiAction, + getProjectComponentsAiAction, + getProjectVersionsAiAction, + getProjectAiAction, + getRecentProjectsAiAction, + getRemoteIssueLinksAiAction, + getSprintAiAction, + getStatusesAiAction, + getUserGroupsAiAction, + getWorklogsAiAction, + listBoardsAiAction, + listCommentsAiAction, + listProjectsAiAction, + listSprintsAiAction, + moveIssuesToSprintAiAction, + parseJqlAiAction, + removeWatcherAiAction, + searchDashboardsAiAction, + searchFiltersAiAction, + searchIssuesByJqlAiAction, + sendIssueNotificationAiAction, + transitionIssueStatusAiAction, + updateCommentAiAction, + uploadAttachmentAiAction, createCustomApiCallAction({ baseUrl: (auth) => { return auth ? `${(auth).props.instanceUrl}/rest/api/3` : ''; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/add-attachment-to-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/add-attachment-to-issue.ts index eb2d51419b10..565a866bab7b 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/add-attachment-to-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/add-attachment-to-issue.ts @@ -5,17 +5,19 @@ import { sendJiraRequest } from '../common'; import FormData from 'form-data'; import { getProjectIdDropdown, getIssueIdDropdown } from '../common/props'; +import { legacyAttachmentListOutputSchema } from '../output-schemas'; export const addAttachmentToIssueAction = createAction({ auth: jiraCloudAuth, name: 'add_issue_attachment', displayName: 'Add Attachment to Issue', description: 'Adds an attachment to an issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Upload a file as an attachment on an existing Jira issue, identified by project and issue. Use when a file (image, log, document) needs to be stored on the issue itself rather than referenced in a comment. Not idempotent: re-running uploads a duplicate copy of the same file.', idempotent: false, }, + outputSchema: legacyAttachmentListOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/add-comment-to-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/add-comment-to-issue.ts index 0aa94f5d2440..343aa5b0bcef 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/add-comment-to-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/add-comment-to-issue.ts @@ -4,17 +4,19 @@ import { HttpMethod } from '@activepieces/pieces-common'; import { sendJiraRequest } from '../common'; import { getIssueIdDropdown, getProjectIdDropdown } from '../common/props'; +import { commentOutputSchema } from '../output-schemas'; export const addCommentToIssueAction = createAction({ auth: jiraCloudAuth, name: 'add_issue_comment', displayName: 'Add Issue Comment', description: 'Adds a comment to an issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Post a new comment on an existing Jira issue. Accepts plain text by default, or raw Atlassian Document Format (ADF) JSON when the ADF flag is set — use the Markdown to Jira Format action to produce ADF from markdown. Not idempotent: each run appends another comment.', idempotent: false, }, + outputSchema: commentOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/add-watcher-to-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/add-watcher-to-issue.ts index 517b0ad50568..abb1fee8c433 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/add-watcher-to-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/add-watcher-to-issue.ts @@ -5,17 +5,19 @@ import { isNil } from '@activepieces/pieces-framework'; import { jiraApiCall } from '../common'; import { HttpError, HttpMethod } from '@activepieces/pieces-common'; +import { legacyAddWatcherOutputSchema } from '../output-schemas'; export const addWatcherToIssueAction = createAction({ auth: jiraCloudAuth, name: 'add-watcher-to-issue', displayName: 'Add Watcher to Issue', description: 'Adds a new watcher to an issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Subscribe a specific user as a watcher on a Jira issue (by issue ID/key and user account) so they get notifications about its updates. Use to loop someone in without assigning or commenting. Effectively idempotent: adding a user who already watches the issue leaves it unchanged.', idempotent: true, }, + outputSchema: legacyAddWatcherOutputSchema, props: { issueId: issueIdOrKeyProp('Issue ID or Key', true), userId: getUsersDropdown({ diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-comment.ts new file mode 100644 index 000000000000..ba454d868def --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-comment.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { commentOutputSchema } from '../../output-schemas'; +export const addCommentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'add_comment', + classification: 'WRITE', + displayName: 'Add Comment', + description: 'Adds a comment to an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Post a new comment on an issue. The text is written in markdown and converted to Jira rich text automatically, so no separate conversion step is needed. Not idempotent: each call adds another comment, so check List Comments before retrying.', + idempotent: false, + }, + outputSchema: commentOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + comment: jiraAiProps.markdownText({ displayName: 'Comment', description: 'The comment text.' }), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/comment`, + body: { body: jiraAiHelpers.markdownToAdf({ markdown: propsValue.comment }) }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-watcher.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-watcher.ts new file mode 100644 index 000000000000..f7e3d4f8844a --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-watcher.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiProps } from '../../common/ai-props'; + +import { watcherChangeOutputSchema } from '../../output-schemas'; +export const addWatcherAiAction = createAction({ + auth: jiraCloudAuth, + name: 'add_watcher', + classification: 'WRITE', + displayName: 'Add Watcher', + description: 'Adds a user to the watchers of an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Add one user, by account ID, to the watcher list of an issue so they get its notifications. Adding someone other than yourself needs the Manage watcher list permission. Idempotent: adding an existing watcher changes nothing.', + idempotent: true, + }, + outputSchema: watcherChangeOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + accountId: jiraAiProps.accountId(), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const accountId = propsValue.accountId.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/watchers`, + body: JSON.stringify(accountId), + }); + return { success: true, issue: issueIdOrKey, account_id: accountId }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-worklog.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-worklog.ts new file mode 100644 index 000000000000..a9ab14af6e9b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/add-worklog.ts @@ -0,0 +1,83 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import dayjs from 'dayjs'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { worklogOutputSchema } from '../../output-schemas'; +export const addWorklogAiAction = createAction({ + auth: jiraCloudAuth, + name: 'add_worklog', + classification: 'WRITE', + displayName: 'Add Worklog', + description: 'Logs time spent on an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Log time spent on an issue as a worklog entry, with an optional start time and comment, adjusting the remaining estimate automatically unless told otherwise. Time tracking must be enabled on the site. Not idempotent: each call adds another entry, so use Get Worklogs to check before retrying.', + idempotent: false, + }, + outputSchema: worklogOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + timeSpent: Property.ShortText({ + displayName: 'Time Spent', + description: 'Time spent in Jira duration format, e.g. 3h 20m, 1d, 45m.', + required: true, + }), + started: Property.DateTime({ + displayName: 'Started', + description: 'When the work started (ISO 8601). Defaults to now.', + required: false, + }), + comment: jiraAiProps.optionalMarkdownText({ displayName: 'Comment', description: 'Optional description of the work done.' }), + adjustEstimate: Property.StaticDropdown({ + displayName: 'Adjust Remaining Estimate', + description: 'How to update the remaining estimate. Defaults to auto (reduce by the time spent).', + required: false, + options: { + options: [ + { label: 'Auto (reduce by time spent)', value: 'auto' }, + { label: 'Leave unchanged', value: 'leave' }, + { label: 'Set to New Estimate', value: 'new' }, + { label: 'Reduce by Reduce By', value: 'manual' }, + ], + }, + }), + newEstimate: Property.ShortText({ + displayName: 'New Estimate', + description: 'New remaining estimate, e.g. 2d. Required when Adjust Remaining Estimate is Set to New Estimate.', + required: false, + }), + reduceBy: Property.ShortText({ + displayName: 'Reduce By', + description: 'Amount to reduce the remaining estimate by, e.g. 2h. Required when Adjust Remaining Estimate is Reduce by Reduce By.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const started = jiraAiHelpers.isProvided(propsValue.started) ? dayjs(propsValue.started) : dayjs(); + if (!started.isValid()) { + throw new Error('Started must be a valid ISO 8601 date-time.'); + } + return jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/worklog`, + query: { + adjustEstimate: propsValue.adjustEstimate, + newEstimate: propsValue.adjustEstimate === 'new' ? propsValue.newEstimate : undefined, + reduceBy: propsValue.adjustEstimate === 'manual' ? propsValue.reduceBy : undefined, + }, + body: { + timeSpent: propsValue.timeSpent.trim(), + started: started.format(JIRA_DATE_TIME_FORMAT), + ...(jiraAiHelpers.isProvided(propsValue.comment) ? { comment: jiraAiHelpers.markdownToAdf({ markdown: propsValue.comment }) } : {}), + }, + }); + }, +}); + +const JIRA_DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZZ'; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/assign-issue-to-user.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/assign-issue-to-user.ts new file mode 100644 index 000000000000..1a13a73f24c5 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/assign-issue-to-user.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { assignIssueToUserOutputSchema } from '../../output-schemas'; +export const assignIssueToUserAiAction = createAction({ + auth: jiraCloudAuth, + name: 'assign_issue_to_user', + classification: 'WRITE', + displayName: 'Assign Issue to User', + description: 'Assigns or unassigns an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Assign an issue to a user by account ID, or unassign it by leaving the account ID empty. Resolve a name or email to an account ID with Find Users, or use Get Current User for yourself. The user must be assignable in the project. Idempotent: assigning the same user again changes nothing.', + idempotent: true, + }, + outputSchema: assignIssueToUserOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + accountId: Property.ShortText({ + displayName: 'Assignee Account ID', + description: 'Account ID of the new assignee. Leave empty to unassign the issue.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const accountId = jiraAiHelpers.isProvided(propsValue.accountId) ? propsValue.accountId.trim() : null; + await jiraApiCall({ + auth, + method: HttpMethod.PUT, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/assignee`, + body: { accountId }, + }); + return { success: true, issue: issueIdOrKey, assignee_account_id: accountId }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-create-issues.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-create-issues.ts new file mode 100644 index 000000000000..3ae94ff44316 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-create-issues.ts @@ -0,0 +1,83 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { bulkCreateIssuesOutputSchema } from '../../output-schemas'; +export const bulkCreateIssuesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'bulk_create_issues', + classification: 'WRITE', + displayName: 'Bulk Create Issues', + description: 'Creates up to 50 issues in one request.', + audience: 'ai', + aiMetadata: { + description: + 'Create up to 50 issues in one project in a single request, each with its own summary, issue type and optional fields. Use it instead of repeated Create Issue with Fields calls when adding many issues; failures are reported per issue while the rest are still created. Not idempotent: each run creates new issues.', + idempotent: false, + }, + outputSchema: bulkCreateIssuesOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey({ description: 'The project key (e.g. PROJ) or numeric project ID that every issue is created in. Find it with List Projects.' }), + issues: Property.Json({ + displayName: 'Issues', + description: + 'JSON array (1-50 items). Each item: {"summary": string, "issueTypeId": string, "description"?: markdown string, "assigneeAccountId"?: string, "priorityId"?: string, "labels"?: string[], "parentKey"?: string, "dueDate"?: "YYYY-MM-DD", "fields"?: object of extra field IDs to values}. Issue type IDs come from Get Issue Types.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const issues = jiraAiHelpers.toRecordList({ value: propsValue.issues, label: 'Issues' }); + if (issues.length === 0 || issues.length > MAX_ISSUES) { + throw new Error(`Issues must contain between 1 and ${MAX_ISSUES} items.`); + } + const issueUpdates = issues.map((issue, index) => ({ + fields: jiraAiHelpers.buildIssueFields({ + projectIdOrKey: propsValue.projectIdOrKey, + issueTypeId: requiredText({ value: issue['issueTypeId'], label: `issues[${index}].issueTypeId` }), + summary: requiredText({ value: issue['summary'], label: `issues[${index}].summary` }), + description: optionalText({ value: issue['description'] }), + assigneeAccountId: optionalText({ value: issue['assigneeAccountId'] }), + priorityId: optionalText({ value: issue['priorityId'] }), + labels: jiraAiHelpers.toStringList({ value: issue['labels'] }), + parentKey: optionalText({ value: issue['parentKey'] }), + dueDate: optionalText({ value: issue['dueDate'] }), + extraFields: jiraAiHelpers.toRecord({ value: issue['fields'], label: `issues[${index}].fields` }), + }), + })); + const response = await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: '/issue/bulk', + body: { issueUpdates }, + }); + const created = response.issues ?? []; + const errors = response.errors ?? []; + return { + issues: created, + errors, + created_count: created.length, + failed_count: errors.length, + }; + }, +}); + +function requiredText({ value, label }: { value: unknown; label: string }): string { + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error(`${label} is required.`); + } + return String(value); +} + +function optionalText({ value }: { value: unknown }): string | undefined { + return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; +} + +const MAX_ISSUES = 50; + +type BulkCreateResponse = { + issues?: JiraRecord[]; + errors?: JiraRecord[]; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-get-issues.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-get-issues.ts new file mode 100644 index 000000000000..1da537409b4b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/bulk-get-issues.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { bulkGetIssuesOutputSchema } from '../../output-schemas'; +export const bulkGetIssuesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'bulk_get_issues', + classification: 'READ', + displayName: 'Bulk Get Issues', + description: 'Fetches up to 100 issues by ID or key in one request.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch up to 100 known issues by key or ID in a single request, optionally limiting the returned fields. Use it when you already hold a list of issue keys; to find issues by criteria use Search Issues by JQL. Keys that do not exist or are not visible are left out of issues without an error, so compare the returned keys with the requested ones. Read-only.', + idempotent: true, + }, + outputSchema: bulkGetIssuesOutputSchema, + props: { + issueIdsOrKeys: Property.Array({ + displayName: 'Issue IDs or Keys', + description: 'Up to 100 issue keys (e.g. PROJ-123) or numeric issue IDs.', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field IDs to return, e.g. summary, status, assignee, customfield_10016. Leave empty for all navigable fields.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const issueIdsOrKeys = jiraAiHelpers.toStringList({ value: propsValue.issueIdsOrKeys }); + if (issueIdsOrKeys.length === 0 || issueIdsOrKeys.length > MAX_ISSUES) { + throw new Error(`Issue IDs or Keys must contain between 1 and ${MAX_ISSUES} items.`); + } + const fields = jiraAiHelpers.toStringList({ value: propsValue.fields }); + const response = await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: '/issue/bulkfetch', + body: { + issueIdsOrKeys, + ...(fields.length > 0 ? { fields } : {}), + }, + }); + const issues = response.issues ?? []; + return { issues, issue_errors: response.issueErrors ?? [], count: issues.length }; + }, +}); + +const MAX_ISSUES = 100; + +type BulkFetchResponse = { + issues?: JiraRecord[]; + issueErrors?: JiraRecord[]; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/count-issues.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/count-issues.ts new file mode 100644 index 000000000000..f562a5bfa79c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/count-issues.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; + +import { countIssuesOutputSchema } from '../../output-schemas'; +export const countIssuesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'count_issues', + classification: 'SEARCH', + displayName: 'Count Issues', + description: 'Returns an approximate count of issues matching a JQL query.', + audience: 'ai', + aiMetadata: { + description: + 'Return an approximate count of the issues matching a JQL query without fetching them. Use it for totals and reporting; to read the issues use Search Issues by JQL. The JQL must be bounded by at least one restriction such as a project, assignee or date clause, otherwise Jira rejects it. Read-only.', + idempotent: true, + }, + outputSchema: countIssuesOutputSchema, + props: { + jql: Property.LongText({ + displayName: 'JQL', + description: 'A bounded JQL query, e.g. project = PROJ AND status = "In Progress".', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall<{ count: number }>({ + auth, + method: HttpMethod.POST, + resourceUri: '/search/approximate-count', + body: { jql: propsValue.jql }, + }); + return { count: response.count, jql: propsValue.jql }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-board.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-board.ts new file mode 100644 index 000000000000..967dc218a060 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-board.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { boardOutputSchema } from '../../output-schemas'; +export const createBoardAiAction = createAction({ + auth: jiraCloudAuth, + name: 'create_board', + classification: 'WRITE', + displayName: 'Create Board', + description: 'Creates a Scrum or Kanban board from a saved filter.', + audience: 'ai', + aiMetadata: { + description: + 'Create a Scrum or Kanban board whose issues come from an existing saved filter, optionally placing it in a project. The filter ID comes from Search Filters, and the user must be able to see that filter. Requires Jira Software. Not idempotent: each call creates another board.', + idempotent: false, + }, + outputSchema: boardOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Board Type', + required: true, + options: { + options: [ + { label: 'Scrum', value: 'scrum' }, + { label: 'Kanban', value: 'kanban' }, + ], + }, + }), + filterId: Property.Number({ + displayName: 'Filter ID', + description: 'ID of the saved filter that selects the board issues. Find it with Search Filters.', + required: true, + }), + projectKeyOrId: Property.ShortText({ + displayName: 'Project ID or Key', + description: 'Project to place the board in. Leave empty to create it in the user profile.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${auth.props.instanceUrl}/rest/agile/1.0/board`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + body: { + name: propsValue.name, + type: propsValue.type, + filterId: propsValue.filterId, + ...(jiraAiHelpers.isProvided(propsValue.projectKeyOrId) + ? { location: { type: 'project', projectKeyOrId: propsValue.projectKeyOrId.trim() } } + : {}), + }, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-link.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-link.ts new file mode 100644 index 000000000000..c48f0eef2b84 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-link.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { createIssueLinkOutputSchema } from '../../output-schemas'; +export const createIssueLinkAiAction = createAction({ + auth: jiraCloudAuth, + name: 'create_issue_link', + classification: 'WRITE', + displayName: 'Create Issue Link', + description: 'Links two issues with a link type.', + audience: 'ai', + aiMetadata: { + description: + 'Link two issues with a named link type such as Blocks, Relates or Duplicates, with an optional comment on the inward issue. Direction matters: the outward issue gets the outward wording (e.g. "blocks") and the inward issue the inward wording (e.g. "is blocked by"); get valid names and wording from Get Issue Link Types. Not idempotent: Jira may add a duplicate link.', + idempotent: false, + }, + outputSchema: createIssueLinkOutputSchema, + props: { + linkTypeName: Property.ShortText({ + displayName: 'Link Type Name', + description: 'Name of the link type, e.g. Blocks. Find it with Get Issue Link Types.', + required: true, + }), + outwardIssueKey: jiraAiProps.issueIdOrKey({ + displayName: 'Outward Issue Key', + description: 'The issue on the outward side, e.g. the issue that blocks the other one.', + }), + inwardIssueKey: jiraAiProps.issueIdOrKey({ + displayName: 'Inward Issue Key', + description: 'The issue on the inward side, e.g. the issue that is blocked.', + }), + comment: jiraAiProps.optionalMarkdownText({ displayName: 'Comment', description: 'Optional comment added to the inward issue.' }), + }, + async run({ auth, propsValue }) { + const outwardIssueKey = propsValue.outwardIssueKey.trim(); + const inwardIssueKey = propsValue.inwardIssueKey.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: '/issueLink', + body: { + type: { name: propsValue.linkTypeName.trim() }, + outwardIssue: { key: outwardIssueKey }, + inwardIssue: { key: inwardIssueKey }, + ...(jiraAiHelpers.isProvided(propsValue.comment) + ? { comment: { body: jiraAiHelpers.markdownToAdf({ markdown: propsValue.comment }) } } + : {}), + }, + }); + return { + success: true, + link_type: propsValue.linkTypeName.trim(), + outward_issue: outwardIssueKey, + inward_issue: inwardIssueKey, + }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-with-fields.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-with-fields.ts new file mode 100644 index 000000000000..67add3f71459 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-issue-with-fields.ts @@ -0,0 +1,98 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { createdIssueOutputSchema } from '../../output-schemas'; +export const createIssueWithFieldsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'create_issue_with_fields', + classification: 'WRITE', + displayName: 'Create Issue with Fields', + description: 'Creates an issue from a project, issue type, summary and optional fields.', + audience: 'ai', + aiMetadata: { + description: + 'Create one issue in a project with a summary, issue type and optional description, assignee, priority, labels, parent, due date and any custom fields by field ID. Get the issue type ID from Get Issue Types and the required custom fields from Get Issue Type Create Fields; for many issues at once use Bulk Create Issues. Not idempotent: each call creates a new issue.', + idempotent: false, + }, + outputSchema: createdIssueOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + issueTypeId: Property.ShortText({ + displayName: 'Issue Type ID', + description: 'The numeric issue type ID. Find it with Get Issue Types.', + required: true, + }), + summary: Property.ShortText({ + displayName: 'Summary', + required: true, + }), + description: jiraAiProps.optionalMarkdownText({ displayName: 'Description', description: 'Issue description.' }), + assigneeAccountId: Property.ShortText({ + displayName: 'Assignee Account ID', + description: 'Account ID of the assignee. Resolve it with Find Users.', + required: false, + }), + priorityId: Property.ShortText({ + displayName: 'Priority ID', + description: 'The priority ID. Find it with Get Priorities.', + required: false, + }), + labels: Property.Array({ + displayName: 'Labels', + description: 'Labels to add. Labels cannot contain spaces.', + required: false, + }), + parentKey: Property.ShortText({ + displayName: 'Parent Issue Key', + description: 'Key of the parent issue or epic. Required when the issue type is a subtask.', + required: false, + }), + dueDate: Property.ShortText({ + displayName: 'Due Date', + description: 'Due date in YYYY-MM-DD format.', + required: false, + }), + fields: Property.Json({ + displayName: 'Additional Fields', + description: + 'JSON object of other field IDs to values in Jira REST format, e.g. {"customfield_10016": 5, "components": [{"id": "10000"}], "fixVersions": [{"id": "10001"}]}. Field IDs come from Get Fields or Get Issue Type Create Fields.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const fields = jiraAiHelpers.buildIssueFields({ + projectIdOrKey: propsValue.projectIdOrKey, + issueTypeId: propsValue.issueTypeId, + summary: propsValue.summary, + description: propsValue.description, + assigneeAccountId: propsValue.assigneeAccountId, + priorityId: propsValue.priorityId, + labels: jiraAiHelpers.toStringList({ value: propsValue.labels }), + parentKey: propsValue.parentKey, + dueDate: propsValue.dueDate, + extraFields: jiraAiHelpers.toRecord({ value: propsValue.fields, label: 'Additional Fields' }), + }); + const created = await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: '/issue', + body: { fields }, + }); + return { + id: created.id, + key: created.key, + self: created.self, + browse_url: jiraAiHelpers.browseUrl({ auth, issueKey: created.key }), + }; + }, +}); + +type CreatedIssue = { + id: string; + key: string; + self: string; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-sprint.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-sprint.ts new file mode 100644 index 000000000000..effed0ffc199 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/create-sprint.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { sprintOutputSchema } from '../../output-schemas'; +export const createSprintAiAction = createAction({ + auth: jiraCloudAuth, + name: 'create_sprint', + classification: 'WRITE', + displayName: 'Create Sprint', + description: 'Creates a future sprint on a Scrum board.', + audience: 'ai', + aiMetadata: { + description: + 'Create a sprint on a Scrum board with optional start date, end date and goal. The sprint is always created in the future state; starting it is done in Jira. Board IDs come from List Boards. Not idempotent: each call creates another sprint.', + idempotent: false, + }, + outputSchema: sprintOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + originBoardId: Property.Number({ + displayName: 'Board ID', + description: 'The numeric ID of the Scrum board. Find it with List Boards.', + required: true, + }), + startDate: Property.DateTime({ + displayName: 'Start Date', + description: 'Planned start (ISO 8601).', + required: false, + }), + endDate: Property.DateTime({ + displayName: 'End Date', + description: 'Planned end (ISO 8601).', + required: false, + }), + goal: Property.LongText({ + displayName: 'Goal', + required: false, + }), + }, + async run({ auth, propsValue }) { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${auth.props.instanceUrl}/rest/agile/1.0/sprint`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + body: { + name: propsValue.name, + originBoardId: propsValue.originBoardId, + ...(jiraAiHelpers.isProvided(propsValue.startDate) ? { startDate: propsValue.startDate } : {}), + ...(jiraAiHelpers.isProvided(propsValue.endDate) ? { endDate: propsValue.endDate } : {}), + ...(jiraAiHelpers.isProvided(propsValue.goal) ? { goal: propsValue.goal } : {}), + }, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-attachment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-attachment.ts new file mode 100644 index 000000000000..f14fcd433a97 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-attachment.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; + +import { deleteAttachmentOutputSchema } from '../../output-schemas'; +export const deleteAttachmentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'delete_attachment', + classification: 'DESTRUCTIVE', + displayName: 'Delete Attachment', + description: 'Permanently deletes an attachment.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete an attachment from its issue by attachment ID. This cannot be undone, so confirm with the user first. Attachment IDs are in the attachment field of Fetch Issue. Not idempotent: a retry returns not found.', + idempotent: false, + }, + outputSchema: deleteAttachmentOutputSchema, + props: { + attachmentId: Property.ShortText({ + displayName: 'Attachment ID', + description: 'The numeric attachment ID, from the attachment field of Fetch Issue.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const attachmentId = propsValue.attachmentId.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.DELETE, + resourceUri: `/attachment/${encodeURIComponent(attachmentId)}`, + }); + return { success: true, attachment_id: attachmentId, deleted: true }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-comment.ts new file mode 100644 index 000000000000..1a3f2b4fbf19 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-comment.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiProps } from '../../common/ai-props'; + +import { deleteCommentOutputSchema } from '../../output-schemas'; +export const deleteCommentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'delete_comment', + classification: 'DESTRUCTIVE', + displayName: 'Delete Comment', + description: 'Permanently deletes a comment from an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete one comment from an issue. This cannot be undone; to change the text use Update Comment instead. Comment IDs come from List Comments. Not idempotent: a retry returns not found.', + idempotent: false, + }, + outputSchema: deleteCommentOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + commentId: Property.ShortText({ + displayName: 'Comment ID', + description: 'The numeric comment ID. Find it with List Comments.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const commentId = propsValue.commentId.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.DELETE, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/comment/${encodeURIComponent(commentId)}`, + }); + return { success: true, issue: issueIdOrKey, comment_id: commentId, deleted: true }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-issue.ts new file mode 100644 index 000000000000..8280478c28d2 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-issue.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiProps } from '../../common/ai-props'; + +import { deleteIssueOutputSchema } from '../../output-schemas'; +export const deleteIssueAiAction = createAction({ + auth: jiraCloudAuth, + name: 'delete_issue', + classification: 'DESTRUCTIVE', + displayName: 'Delete Issue', + description: 'Permanently deletes an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete a Jira issue and, if requested, its subtasks. This cannot be undone, so confirm with the user first; to close an issue instead, use Transition Issue Status. Fails when the issue has subtasks and Delete Subtasks is off. Not idempotent: a retry returns not found.', + idempotent: false, + }, + outputSchema: deleteIssueOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + deleteSubtasks: Property.Checkbox({ + displayName: 'Delete Subtasks', + description: 'Also delete the subtasks of the issue. Required when the issue has subtasks.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.DELETE, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}`, + query: { deleteSubtasks: String(propsValue.deleteSubtasks === true) }, + }); + return { success: true, issue: issueIdOrKey, deleted: true }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-worklog.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-worklog.ts new file mode 100644 index 000000000000..2b51ef5f3fa3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/delete-worklog.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiProps } from '../../common/ai-props'; + +import { deleteWorklogOutputSchema } from '../../output-schemas'; +export const deleteWorklogAiAction = createAction({ + auth: jiraCloudAuth, + name: 'delete_worklog', + classification: 'DESTRUCTIVE', + displayName: 'Delete Worklog', + description: 'Deletes a worklog from an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete one worklog entry from an issue, restoring the logged time to the remaining estimate automatically unless told otherwise. Worklog IDs come from Get Worklogs. Not idempotent: a retry returns not found.', + idempotent: false, + }, + outputSchema: deleteWorklogOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + worklogId: Property.ShortText({ + displayName: 'Worklog ID', + description: 'The numeric worklog ID. Find it with Get Worklogs.', + required: true, + }), + adjustEstimate: Property.StaticDropdown({ + displayName: 'Adjust Remaining Estimate', + description: 'How to update the remaining estimate. Defaults to auto (increase by the deleted time).', + required: false, + options: { + options: [ + { label: 'Auto (increase by deleted time)', value: 'auto' }, + { label: 'Leave unchanged', value: 'leave' }, + { label: 'Set to New Estimate', value: 'new' }, + { label: 'Increase by Increase By', value: 'manual' }, + ], + }, + }), + newEstimate: Property.ShortText({ + displayName: 'New Estimate', + description: 'New remaining estimate, e.g. 2d. Required when Adjust Remaining Estimate is Set to New Estimate.', + required: false, + }), + increaseBy: Property.ShortText({ + displayName: 'Increase By', + description: 'Amount to increase the remaining estimate by, e.g. 2h. Required when Adjust Remaining Estimate is Increase by Increase By.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const worklogId = propsValue.worklogId.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.DELETE, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/worklog/${encodeURIComponent(worklogId)}`, + query: { + adjustEstimate: propsValue.adjustEstimate, + newEstimate: propsValue.adjustEstimate === 'new' ? propsValue.newEstimate : undefined, + increaseBy: propsValue.adjustEstimate === 'manual' ? propsValue.increaseBy : undefined, + }, + }); + return { success: true, issue: issueIdOrKey, worklog_id: worklogId, deleted: true }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/download-attachment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/download-attachment.ts new file mode 100644 index 000000000000..c3625aa641b8 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/download-attachment.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; + +import { downloadAttachmentOutputSchema } from '../../output-schemas'; +export const downloadAttachmentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'download_attachment', + classification: 'READ', + displayName: 'Download Attachment', + description: 'Downloads an attachment file and its metadata.', + audience: 'ai', + aiMetadata: { + description: + 'Download an attachment by attachment ID and return its file together with its name, MIME type, size, author and creation date. Attachment IDs are in the attachment field of Fetch Issue. Read-only.', + idempotent: true, + }, + outputSchema: downloadAttachmentOutputSchema, + props: { + attachmentId: Property.ShortText({ + displayName: 'Attachment ID', + description: 'The numeric attachment ID, from the attachment field of Fetch Issue.', + required: true, + }), + }, + async run({ auth, propsValue, files }) { + const attachmentId = propsValue.attachmentId.trim(); + const metadata = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/attachment/${encodeURIComponent(attachmentId)}`, + }); + const content = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${auth.props.instanceUrl}/rest/api/3/attachment/content/${encodeURIComponent(attachmentId)}`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + responseType: 'arraybuffer', + followRedirects: true, + }); + const file = await files.write({ fileName: metadata.filename, data: Buffer.from(content.body) }); + return { + id: metadata.id, + filename: metadata.filename, + mime_type: metadata.mimeType ?? null, + size: metadata.size ?? null, + created: metadata.created ?? null, + author_account_id: metadata.author?.accountId ?? null, + author_display_name: metadata.author?.displayName ?? null, + file, + }; + }, +}); + +type AttachmentMetadata = { + id: string; + filename: string; + mimeType?: string; + size?: number; + created?: string; + author?: { accountId?: string; displayName?: string }; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/edit-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/edit-issue.ts new file mode 100644 index 000000000000..c98c9b947eae --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/edit-issue.ts @@ -0,0 +1,98 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueOutputSchema } from '../../output-schemas'; +export const editIssueAiAction = createAction({ + auth: jiraCloudAuth, + name: 'edit_issue', + classification: 'WRITE', + displayName: 'Edit Issue', + description: 'Updates only the given fields of an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Update selected fields of an existing issue: summary, description, assignee, priority, labels, parent, due date, issue type or any field by ID. Only the fields you supply change; everything else is left alone, and labels replace the current list. Status is not a field, use Transition Issue Status for that. Idempotent: applying the same values again changes nothing.', + idempotent: true, + }, + outputSchema: issueOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + summary: Property.ShortText({ + displayName: 'Summary', + required: false, + }), + description: jiraAiProps.optionalMarkdownText({ displayName: 'Description', description: 'New issue description, replacing the current one.' }), + assigneeAccountId: Property.ShortText({ + displayName: 'Assignee Account ID', + description: 'Account ID of the new assignee. Resolve it with Find Users; to unassign use Assign Issue to User.', + required: false, + }), + priorityId: Property.ShortText({ + displayName: 'Priority ID', + description: 'The priority ID. Find it with Get Priorities.', + required: false, + }), + labels: Property.Array({ + displayName: 'Labels', + description: 'Labels that replace the current labels. Pass an empty list to remove all labels. Labels cannot contain spaces.', + required: false, + }), + parentKey: Property.ShortText({ + displayName: 'Parent Issue Key', + description: 'Key of the new parent issue or epic.', + required: false, + }), + dueDate: Property.ShortText({ + displayName: 'Due Date', + description: 'Due date in YYYY-MM-DD format.', + required: false, + }), + issueTypeId: Property.ShortText({ + displayName: 'Issue Type ID', + description: 'New issue type ID. Find it with Get Issue Types.', + required: false, + }), + fields: Property.Json({ + displayName: 'Additional Fields', + description: + 'JSON object of other field IDs to values in Jira REST format, e.g. {"customfield_10016": 8}. Set a field to null to clear it. Editable field IDs come from Get Issue Edit Metadata.', + required: false, + }), + notifyUsers: Property.Checkbox({ + displayName: 'Notify Watchers', + description: 'Send the usual update email to watchers. Turning it off needs admin rights.', + required: false, + defaultValue: true, + }), + }, + async run({ auth, propsValue }) { + const labels = jiraAiHelpers.toStringList({ value: propsValue.labels }); + const fields = { + ...jiraAiHelpers.buildIssueFields({ + issueTypeId: propsValue.issueTypeId, + summary: propsValue.summary, + description: propsValue.description, + assigneeAccountId: propsValue.assigneeAccountId, + priorityId: propsValue.priorityId, + parentKey: propsValue.parentKey, + dueDate: propsValue.dueDate, + extraFields: jiraAiHelpers.toRecord({ value: propsValue.fields, label: 'Additional Fields' }), + }), + ...(Array.isArray(propsValue.labels) ? { labels } : {}), + }; + if (Object.keys(fields).length === 0) { + throw new Error('Provide at least one field to update.'); + } + return jiraApiCall({ + auth, + method: HttpMethod.PUT, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}`, + query: { returnIssue: 'true', notifyUsers: String(propsValue.notifyUsers !== false) }, + body: { fields }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/fetch-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/fetch-issue.ts new file mode 100644 index 000000000000..3ed1093beff9 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/fetch-issue.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueOutputSchema } from '../../output-schemas'; +export const fetchIssueAiAction = createAction({ + auth: jiraCloudAuth, + name: 'fetch_issue', + classification: 'READ', + displayName: 'Fetch Issue', + description: 'Gets an issue by key or ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch one issue by key or ID with its fields (status, assignee, priority, description, attachments, issue links, custom fields), optionally limited to chosen fields and expanded with rendered HTML, field names, transitions or changelog. Use it when you know the issue; to find issues by criteria use Search Issues by JQL. Read-only.', + idempotent: true, + }, + outputSchema: issueOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field IDs to return, e.g. summary, status, customfield_10016. Leave empty for all fields.', + required: false, + }), + expand: Property.StaticMultiSelectDropdown({ + displayName: 'Expand', + required: false, + options: { + options: [ + { label: 'Rendered Fields (HTML)', value: 'renderedFields' }, + { label: 'Field Names', value: 'names' }, + { label: 'Transitions', value: 'transitions' }, + { label: 'Changelog', value: 'changelog' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + const fields = jiraAiHelpers.toStringList({ value: propsValue.fields }); + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}`, + query: { + fields: fields.length > 0 ? fields : undefined, + expand: propsValue.expand, + }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/find-users.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/find-users.ts new file mode 100644 index 000000000000..adc480437025 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/find-users.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { usersOutputSchema } from '../../output-schemas'; +export const findUsersAiAction = createAction({ + auth: jiraCloudAuth, + name: 'find_users', + classification: 'SEARCH', + displayName: 'Find Users', + description: 'Finds users by name or email.', + audience: 'ai', + aiMetadata: { + description: + 'Find users whose display name or email address matches a query, returning their account IDs, display names, active status and email when visible. The way to turn a person\'s name or email into the account ID that assign, watcher and notification actions need. Email matching depends on site privacy settings. Read-only.', + idempotent: true, + }, + outputSchema: usersOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: 'Text matched against display name and email address, e.g. jane or jane@example.com.', + required: true, + }), + includeInactive: Property.Checkbox({ + displayName: 'Include Inactive Users', + required: false, + defaultValue: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 1000 }), + }, + async run({ auth, propsValue }) { + const users = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/user/search', + query: { query: propsValue.query.trim(), startAt: propsValue.startAt, maxResults: propsValue.maxResults }, + }); + const items = propsValue.includeInactive ? users : users.filter((user) => user['active'] !== false); + return jiraAiHelpers.toList({ items }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-comment.ts new file mode 100644 index 000000000000..2cdfff9eaa83 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-comment.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { commentOutputSchema } from '../../output-schemas'; +export const getCommentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_comment', + classification: 'READ', + displayName: 'Get Comment', + description: 'Gets a single comment on an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch one comment on an issue by its comment ID, including author, timestamps, visibility and the body as Atlassian Document Format plus rendered HTML. Use List Comments to discover comment IDs. Read-only.', + idempotent: true, + }, + outputSchema: commentOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + commentId: Property.ShortText({ + displayName: 'Comment ID', + description: 'The numeric comment ID. Find it with List Comments.', + required: true, + }), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/comment/${encodeURIComponent(propsValue.commentId.trim())}`, + query: { expand: 'renderedBody' }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-current-user.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-current-user.ts new file mode 100644 index 000000000000..440c2c8ab1d6 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-current-user.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; + +import { currentUserOutputSchema } from '../../output-schemas'; +export const getCurrentUserAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Gets the user the connection is authenticated as.', + audience: 'ai', + aiMetadata: { + description: + 'Return the Jira user the connection is authenticated as, including account ID, display name, email (when visible), time zone and groups. Use it to get your own account ID for "assigned to me" requests or for Assign Issue to User. Read-only.', + idempotent: true, + }, + outputSchema: currentUserOutputSchema, + props: {}, + async run({ auth }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/myself', + query: { expand: 'groups' }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-favorite-filters.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-favorite-filters.ts new file mode 100644 index 000000000000..73086cef5952 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-favorite-filters.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { filterListOutputSchema } from '../../output-schemas'; +export const getFavoriteFiltersAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_favorite_filters', + classification: 'SEARCH', + displayName: 'Get Favorite Filters', + description: 'Lists the filters the user has starred.', + audience: 'ai', + aiMetadata: { + description: + 'List the saved filters the connected user has starred as favourites, with each filter ID, name and JQL. A quick way to find the searches the user relies on; use Search Filters to search all visible filters. Read-only.', + idempotent: true, + }, + outputSchema: filterListOutputSchema, + props: {}, + async run({ auth }) { + const filters = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/filter/favourite', + }); + return jiraAiHelpers.toList({ items: filters }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-fields.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-fields.ts new file mode 100644 index 000000000000..e980ecf3df1a --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-fields.ts @@ -0,0 +1,48 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { fieldsListOutputSchema } from '../../output-schemas'; +export const getFieldsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_fields', + classification: 'SEARCH', + displayName: 'Get Fields', + description: 'Lists all system and custom issue fields.', + audience: 'ai', + aiMetadata: { + description: + 'List every system and custom issue field with its ID (e.g. customfield_10016), name and schema type. Use it to translate a field name into the ID that Create Issue, Edit Issue and JQL need; for the fields allowed on one project and issue type use Get Issue Type Create Fields instead. Read-only.', + idempotent: true, + }, + outputSchema: fieldsListOutputSchema, + props: { + nameContains: Property.ShortText({ + displayName: 'Name Contains', + description: 'Only return fields whose name contains this text (case-insensitive).', + required: false, + }), + customOnly: Property.Checkbox({ + displayName: 'Custom Fields Only', + description: 'Only return custom fields.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + const fields = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/field', + }); + const needle = propsValue.nameContains?.trim().toLowerCase() ?? ''; + const items = fields.filter( + (field) => + (!propsValue.customOnly || field['custom'] === true) && + (needle.length === 0 || String(field['name'] ?? '').toLowerCase().includes(needle)), + ); + return jiraAiHelpers.toList({ items }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-filter.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-filter.ts new file mode 100644 index 000000000000..568ed1ea03f4 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-filter.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; + +import { filterOutputSchema } from '../../output-schemas'; +export const getFilterAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_filter', + classification: 'READ', + displayName: 'Get Filter', + description: 'Gets a saved filter, including its JQL.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch one saved filter by ID, including its name, owner, JQL, sharing permissions and view URL. Use it to reuse the filter JQL in Search Issues by JQL or Count Issues. Read-only.', + idempotent: true, + }, + outputSchema: filterOutputSchema, + props: { + filterId: Property.ShortText({ + displayName: 'Filter ID', + description: 'The numeric filter ID. Find it with Search Filters or Get Favorite Filters.', + required: true, + }), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/filter/${encodeURIComponent(propsValue.filterId.trim())}`, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-edit-metadata.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-edit-metadata.ts new file mode 100644 index 000000000000..687c2b78428b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-edit-metadata.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueEditMetadataOutputSchema } from '../../output-schemas'; +export const getIssueEditMetadataAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_edit_metadata', + classification: 'READ', + displayName: 'Get Issue Edit Metadata', + description: 'Lists the fields that can be edited on an issue.', + audience: 'ai', + aiMetadata: { + description: + 'List the fields that can be edited on one existing issue, keyed by field ID, with each field schema, allowed operations and allowed values. Call it before Edit Issue to learn which fields and option IDs the issue accepts. Read-only.', + idempotent: true, + }, + outputSchema: issueEditMetadataOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/editmeta`, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-link-types.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-link-types.ts new file mode 100644 index 000000000000..ad84d65166e1 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-link-types.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { issueLinkTypesOutputSchema } from '../../output-schemas'; +export const getIssueLinkTypesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_link_types', + classification: 'SEARCH', + displayName: 'Get Issue Link Types', + description: 'Lists the issue link types configured in Jira.', + audience: 'ai', + aiMetadata: { + description: + 'List the issue link types configured on the site (e.g. Blocks, Relates, Duplicates), each with its name and inward/outward wording. Call it before Create Issue Link to pick a valid link type name and the correct direction. Read-only.', + idempotent: true, + }, + outputSchema: issueLinkTypesOutputSchema, + props: {}, + async run({ auth }) { + const response = await jiraApiCall<{ issueLinkTypes?: JiraRecord[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/issueLinkType', + }); + return jiraAiHelpers.toList({ items: response.issueLinkTypes ?? [] }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-resolutions.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-resolutions.ts new file mode 100644 index 000000000000..b7a3d7f3bdd3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-resolutions.ts @@ -0,0 +1,41 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { resolutionsOutputSchema } from '../../output-schemas'; +export const getIssueResolutionsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_resolutions', + classification: 'SEARCH', + displayName: 'Get Issue Resolutions', + description: 'Lists the issue resolutions configured in Jira.', + audience: 'ai', + aiMetadata: { + description: + 'List the issue resolutions configured on the site (e.g. Done, Won\'t Do, Duplicate) with their IDs. Use it to find the resolution ID a transition screen requires when closing an issue with Transition Issue Status. Read-only.', + idempotent: true, + }, + outputSchema: resolutionsOutputSchema, + props: { + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: '/resolution/search', + query: { startAt: propsValue.startAt, maxResults: propsValue.maxResults }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-transitions.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-transitions.ts new file mode 100644 index 000000000000..20afaa2a0dad --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-transitions.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueTransitionsOutputSchema } from '../../output-schemas'; +export const getIssueTransitionsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_transitions', + classification: 'READ', + displayName: 'Get Issue Transitions', + description: 'Lists the workflow transitions available on an issue.', + audience: 'ai', + aiMetadata: { + description: + 'List the workflow transitions the connected user can perform on an issue from its current status, each with its numeric ID, name and target status. Call it before Transition Issue Status, which needs the numeric transition ID rather than a status name. Read-only.', + idempotent: true, + }, + outputSchema: issueTransitionsOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + includeFields: Property.Checkbox({ + displayName: 'Include Transition Screen Fields', + description: 'Also return the fields shown on each transition screen, such as a required resolution.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall<{ transitions?: JiraRecord[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/transitions`, + query: { expand: propsValue.includeFields ? 'transitions.fields' : undefined }, + }); + return jiraAiHelpers.toList({ items: response.transitions ?? [] }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-type-create-fields.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-type-create-fields.ts new file mode 100644 index 000000000000..5ec757d20d1c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-type-create-fields.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueTypeCreateFieldsOutputSchema } from '../../output-schemas'; +export const getIssueTypeCreateFieldsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_type_create_fields', + classification: 'READ', + displayName: 'Get Issue Type Create Fields', + description: 'Lists the fields available when creating an issue of a given type in a project.', + audience: 'ai', + aiMetadata: { + description: + 'List the fields that can be set when creating an issue of one issue type in one project, with whether each is required, its schema and its allowed values. Call it before Create Issue with Fields to learn which custom fields are mandatory and what IDs they accept. Issue type IDs come from Get Issue Types. Read-only.', + idempotent: true, + }, + outputSchema: issueTypeCreateFieldsOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + issueTypeId: Property.ShortText({ + displayName: 'Issue Type ID', + description: 'The numeric issue type ID. Find it with Get Issue Types.', + required: true, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ defaultValue: 100, max: 200 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/createmeta/${encodeURIComponent(propsValue.projectIdOrKey.trim())}/issuetypes/${encodeURIComponent(propsValue.issueTypeId.trim())}`, + query: { startAt: propsValue.startAt, maxResults: propsValue.maxResults }, + }); + return jiraAiHelpers.toPage({ + items: response.fields ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + }); + }, +}); + +type CreateMetaFieldsPage = { + fields?: JiraRecord[]; + startAt?: number; + maxResults?: number; + total?: number; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-types.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-types.ts new file mode 100644 index 000000000000..97a5492e3f31 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-types.ts @@ -0,0 +1,48 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { JiraAuth, jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueTypesOutputSchema } from '../../output-schemas'; +export const getIssueTypesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_types', + classification: 'SEARCH', + displayName: 'Get Issue Types', + description: 'Lists the issue types available in a project.', + audience: 'ai', + aiMetadata: { + description: + 'List the issue types (Task, Bug, Story, Epic, Subtask and custom ones) available in one project, with their numeric IDs and whether each is a subtask type. Call it to get the issue type ID that Create Issue with Fields and Get Issue Type Create Fields require. Read-only.', + idempotent: true, + }, + outputSchema: issueTypesOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + }, + async run({ auth, propsValue }) { + const projectId = await resolveProjectId({ auth, projectIdOrKey: propsValue.projectIdOrKey }); + const issueTypes = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/issuetype/project', + query: { projectId }, + }); + return { project_id: projectId, ...jiraAiHelpers.toList({ items: issueTypes }) }; + }, +}); + +async function resolveProjectId({ auth, projectIdOrKey }: { auth: JiraAuth; projectIdOrKey: string }): Promise { + const trimmed = projectIdOrKey.trim(); + if (/^\d+$/.test(trimmed)) { + return trimmed; + } + const project = await jiraApiCall<{ id: string }>({ + auth, + method: HttpMethod.GET, + resourceUri: `/project/${encodeURIComponent(trimmed)}`, + }); + return project.id; +} diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-votes.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-votes.ts new file mode 100644 index 000000000000..394d5828a598 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-votes.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueVotesOutputSchema } from '../../output-schemas'; +export const getIssueVotesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_votes', + classification: 'READ', + displayName: 'Get Issue Votes', + description: 'Gets the vote count and voters of an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Return the number of votes on an issue, whether the connected user has voted, and the voters when the user may see them. Voting must be enabled on the site. Read-only.', + idempotent: true, + }, + outputSchema: issueVotesOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/votes`, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-watchers.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-watchers.ts new file mode 100644 index 000000000000..46b4b3753715 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-issue-watchers.ts @@ -0,0 +1,43 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { issueWatchersOutputSchema } from '../../output-schemas'; +export const getIssueWatchersAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_issue_watchers', + classification: 'SEARCH', + displayName: 'Get Issue Watchers', + description: 'Lists the users watching an issue.', + audience: 'ai', + aiMetadata: { + description: + 'List the users watching an issue, with their account IDs and whether the connected user is one of them. Use it before Add Watcher or Remove Watcher to check the current list. Watcher details may be hidden by site privacy settings. Read-only.', + idempotent: true, + }, + outputSchema: issueWatchersOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/watchers`, + }); + return { + ...jiraAiHelpers.toList({ items: response.watchers ?? [] }), + watch_count: response.watchCount ?? null, + is_watching: response.isWatching ?? null, + }; + }, +}); + +type WatchersResponse = { + watchers?: JiraRecord[]; + watchCount?: number; + isWatching?: boolean; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-field-suggestions.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-field-suggestions.ts new file mode 100644 index 000000000000..4877cb1d9483 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-field-suggestions.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { jqlSuggestionsOutputSchema } from '../../output-schemas'; +export const getJqlFieldSuggestionsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_jql_field_suggestions', + classification: 'SEARCH', + displayName: 'Get JQL Field Suggestions', + description: 'Suggests valid values for a JQL field.', + audience: 'ai', + aiMetadata: { + description: + 'Suggest valid JQL values for one field, e.g. the exact names of statuses, sprints, labels or custom field options, optionally matching partially typed text. Use it to write exact values in JQL for Search Issues by JQL; field names come from Get JQL Reference Data. Read-only.', + idempotent: true, + }, + outputSchema: jqlSuggestionsOutputSchema, + props: { + fieldName: Property.ShortText({ + displayName: 'Field Name', + description: 'The JQL field name, e.g. status, labels, sprint, or cf[10016].', + required: true, + }), + fieldValue: Property.ShortText({ + displayName: 'Partial Value', + description: 'Partially typed value to match.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall<{ results?: JiraRecord[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/jql/autocompletedata/suggestions', + query: { fieldName: propsValue.fieldName.trim(), fieldValue: propsValue.fieldValue }, + }); + return jiraAiHelpers.toList({ items: response.results ?? [] }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-reference-data.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-reference-data.ts new file mode 100644 index 000000000000..41c6cb5c9a9f --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-jql-reference-data.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; + +import { jqlReferenceDataOutputSchema } from '../../output-schemas'; +export const getJqlReferenceDataAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_jql_reference_data', + classification: 'READ', + displayName: 'Get JQL Reference Data', + description: 'Lists the fields, functions and reserved words usable in JQL.', + audience: 'ai', + aiMetadata: { + description: + 'Return the field names (with their operators and types), functions and reserved words usable in JQL on this site, optionally filtered by field name. Use it to write a valid JQL clause for a custom field, then Get JQL Field Suggestions for its values. Read-only.', + idempotent: true, + }, + outputSchema: jqlReferenceDataOutputSchema, + props: { + fieldNameContains: Property.ShortText({ + displayName: 'Field Name Contains', + description: 'Only return fields whose name or display name contains this text (case-insensitive).', + required: false, + }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/jql/autocompletedata', + }); + const needle = propsValue.fieldNameContains?.trim().toLowerCase() ?? ''; + const fields = (response.visibleFieldNames ?? []).filter( + (field) => + needle.length === 0 || + String(field['value'] ?? '').toLowerCase().includes(needle) || + String(field['displayName'] ?? '').toLowerCase().includes(needle), + ); + return { + fields, + field_count: fields.length, + functions: response.visibleFunctionNames ?? [], + reserved_words: response.jqlReservedWords ?? [], + }; + }, +}); + +type JqlReferenceData = { + visibleFieldNames?: JiraRecord[]; + visibleFunctionNames?: JiraRecord[]; + jqlReservedWords?: string[]; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-my-permissions.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-my-permissions.ts new file mode 100644 index 000000000000..4aba5ca6fc13 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-my-permissions.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { myPermissionsOutputSchema } from '../../output-schemas'; +export const getMyPermissionsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_my_permissions', + classification: 'READ', + displayName: 'Get My Permissions', + description: 'Checks which permissions the user has, globally or in a project or issue.', + audience: 'ai', + aiMetadata: { + description: + 'Check whether the connected user holds specific permissions, globally or within one project or issue, returning havePermission for each key. Use it before a write to explain a likely permission failure; to list every project where a permission holds use Get Permitted Projects. Read-only.', + idempotent: true, + }, + outputSchema: myPermissionsOutputSchema, + props: { + permissions: Property.Array({ + displayName: 'Permission Keys', + description: + 'Permission keys to check, e.g. BROWSE_PROJECTS, CREATE_ISSUES, EDIT_ISSUES, TRANSITION_ISSUES, ASSIGN_ISSUES, ADD_COMMENTS, DELETE_ISSUES, WORK_ON_ISSUES, ADMINISTER_PROJECTS.', + required: true, + }), + projectKey: Property.ShortText({ + displayName: 'Project Key', + description: 'Check the permissions within this project.', + required: false, + }), + issueKey: Property.ShortText({ + displayName: 'Issue Key', + description: 'Check the permissions on this issue.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const permissions = jiraAiHelpers.toStringList({ value: propsValue.permissions }); + if (permissions.length === 0) { + throw new Error('Provide at least one permission key.'); + } + const response = await jiraApiCall<{ permissions?: Record }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/mypermissions', + query: { permissions, projectKey: propsValue.projectKey, issueKey: propsValue.issueKey }, + }); + return jiraAiHelpers.toList({ items: Object.values(response.permissions ?? {}) }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-permitted-projects.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-permitted-projects.ts new file mode 100644 index 000000000000..2e0bbd569ce6 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-permitted-projects.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { permittedProjectsOutputSchema } from '../../output-schemas'; +export const getPermittedProjectsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_permitted_projects', + classification: 'SEARCH', + displayName: 'Get Permitted Projects', + description: 'Lists the projects where the user has all given permissions.', + audience: 'ai', + aiMetadata: { + description: + 'List the projects in which the connected user holds every one of the given project permissions, e.g. the projects where they can create issues. Returns project IDs and keys only; use Get Project for details. Read-only.', + idempotent: true, + }, + outputSchema: permittedProjectsOutputSchema, + props: { + permissions: Property.Array({ + displayName: 'Permission Keys', + description: 'Project permission keys that must all be held, e.g. CREATE_ISSUES, EDIT_ISSUES, TRANSITION_ISSUES.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const permissions = jiraAiHelpers.toStringList({ value: propsValue.permissions }); + if (permissions.length === 0) { + throw new Error('Provide at least one permission key.'); + } + const response = await jiraApiCall<{ projects?: JiraRecord[] }>({ + auth, + method: HttpMethod.POST, + resourceUri: '/permissions/project', + body: { permissions }, + }); + return jiraAiHelpers.toList({ items: response.projects ?? [] }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-priorities.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-priorities.ts new file mode 100644 index 000000000000..b2db533d8c44 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-priorities.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { prioritiesOutputSchema } from '../../output-schemas'; +export const getPrioritiesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_priorities', + classification: 'SEARCH', + displayName: 'Get Priorities', + description: 'Lists the issue priorities.', + audience: 'ai', + aiMetadata: { + description: + 'List the issue priorities (e.g. Highest, High, Medium, Low) with their IDs, optionally limited to those available in one project. Use it to get the priority ID that Create Issue with Fields and Edit Issue expect. Read-only.', + idempotent: true, + }, + outputSchema: prioritiesOutputSchema, + props: { + projectId: Property.ShortText({ + displayName: 'Project ID', + description: 'Only return priorities available in this project. Must be the numeric project ID (see Get Project); leave empty for all.', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: '/priority/search', + query: { projectId: propsValue.projectId, startAt: propsValue.startAt, maxResults: propsValue.maxResults }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-components.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-components.ts new file mode 100644 index 000000000000..2309c66f758c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-components.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { componentsOutputSchema } from '../../output-schemas'; +export const getProjectComponentsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_project_components', + classification: 'SEARCH', + displayName: 'Get Project Components', + description: 'Lists the components of a project.', + audience: 'ai', + aiMetadata: { + description: + 'List the components of one project, optionally filtered by name or description text, with their IDs, leads and issue counts. Use it to find component IDs for the components field of Create Issue with Fields or Edit Issue. Read-only.', + idempotent: true, + }, + outputSchema: componentsOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + query: Property.ShortText({ + displayName: 'Query', + description: 'Only return components whose name or description contains this text.', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: `/project/${encodeURIComponent(propsValue.projectIdOrKey.trim())}/component`, + query: { query: propsValue.query, startAt: propsValue.startAt, maxResults: propsValue.maxResults }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-versions.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-versions.ts new file mode 100644 index 000000000000..dba3d97aafca --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project-versions.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { versionsOutputSchema } from '../../output-schemas'; +export const getProjectVersionsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_project_versions', + classification: 'SEARCH', + displayName: 'Get Project Versions', + description: 'Lists the versions (releases) of a project.', + audience: 'ai', + aiMetadata: { + description: + 'List the versions (releases) of one project, optionally filtered by name and by released, unreleased or archived status, with their IDs and release dates. Use it to find the version IDs that the fixVersions field of Create Issue with Fields or Edit Issue expects. Read-only.', + idempotent: true, + }, + outputSchema: versionsOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + query: Property.ShortText({ + displayName: 'Query', + description: 'Only return versions whose name or description contains this text.', + required: false, + }), + status: Property.StaticMultiSelectDropdown({ + displayName: 'Status', + description: 'Only return versions with these statuses.', + required: false, + options: { + options: [ + { label: 'Released', value: 'released' }, + { label: 'Unreleased', value: 'unreleased' }, + { label: 'Archived', value: 'archived' }, + ], + }, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: `/project/${encodeURIComponent(propsValue.projectIdOrKey.trim())}/version`, + query: { + query: propsValue.query, + status: propsValue.status, + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project.ts new file mode 100644 index 000000000000..8b7cf28376af --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-project.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { projectOutputSchema } from '../../output-schemas'; +export const getProjectAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_project', + classification: 'READ', + displayName: 'Get Project', + description: 'Gets the details of a project.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch one project by key or ID, including its numeric ID, name, description, lead, project type, issue types, components and versions. Use List Projects to find a project by name. Read-only.', + idempotent: true, + }, + outputSchema: projectOutputSchema, + props: { + projectIdOrKey: jiraAiProps.projectIdOrKey(), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/project/${encodeURIComponent(propsValue.projectIdOrKey.trim())}`, + query: { expand: 'description,lead,issueTypes,url' }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-recent-projects.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-recent-projects.ts new file mode 100644 index 000000000000..5444f616ca30 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-recent-projects.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { projectListOutputSchema } from '../../output-schemas'; +export const getRecentProjectsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_recent_projects', + classification: 'SEARCH', + displayName: 'Get Recent Projects', + description: 'Lists the projects the user viewed most recently.', + audience: 'ai', + aiMetadata: { + description: + 'List up to 20 projects the connected user viewed most recently, newest first. A quick way to guess which project the user means; use List Projects to search all projects. Read-only.', + idempotent: true, + }, + outputSchema: projectListOutputSchema, + props: {}, + async run({ auth }) { + const projects = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/project/recent', + query: { expand: 'description,lead' }, + }); + return jiraAiHelpers.toList({ items: projects }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-remote-issue-links.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-remote-issue-links.ts new file mode 100644 index 000000000000..bb330608a50b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-remote-issue-links.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { remoteIssueLinksOutputSchema } from '../../output-schemas'; +export const getRemoteIssueLinksAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_remote_issue_links', + classification: 'SEARCH', + displayName: 'Get Remote Issue Links', + description: 'Lists the links from an issue to external resources.', + audience: 'ai', + aiMetadata: { + description: + 'List the remote links on an issue that point outside Jira, such as Confluence pages, pull requests or web URLs, with each title and URL. Links between two Jira issues are not included; read those from the issuelinks field of Fetch Issue. Read-only.', + idempotent: true, + }, + outputSchema: remoteIssueLinksOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + }, + async run({ auth, propsValue }) { + const links = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/remotelink`, + }); + return jiraAiHelpers.toList({ items: links }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-sprint.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-sprint.ts new file mode 100644 index 000000000000..b0a974e6f832 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-sprint.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { JiraRecord } from '../../common/ai-helpers'; + +import { sprintOutputSchema } from '../../output-schemas'; +export const getSprintAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_sprint', + classification: 'READ', + displayName: 'Get Sprint', + description: 'Gets the details of a sprint.', + audience: 'ai', + aiMetadata: { + description: + 'Fetch one sprint by its numeric ID, including name, state, start, end and completion dates, goal and board. Use List Sprints to find sprint IDs. Read-only.', + idempotent: true, + }, + outputSchema: sprintOutputSchema, + props: { + sprintId: Property.Number({ + displayName: 'Sprint ID', + description: 'The numeric sprint ID. Find it with List Sprints.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${auth.props.instanceUrl}/rest/agile/1.0/sprint/${propsValue.sprintId}`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-statuses.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-statuses.ts new file mode 100644 index 000000000000..21d80f0718d3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-statuses.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { statusesOutputSchema } from '../../output-schemas'; +export const getStatusesAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_statuses', + classification: 'SEARCH', + displayName: 'Get Statuses', + description: 'Lists the issue statuses used in active workflows.', + audience: 'ai', + aiMetadata: { + description: + 'List the issue statuses used in active workflows, with their IDs, names and status category (To Do, In Progress, Done). Use it to write valid status names in JQL; to move an issue to a status use Get Issue Transitions, since statuses cannot be set directly. Read-only.', + idempotent: true, + }, + outputSchema: statusesOutputSchema, + props: { + nameContains: Property.ShortText({ + displayName: 'Name Contains', + description: 'Only return statuses whose name contains this text (case-insensitive).', + required: false, + }), + }, + async run({ auth, propsValue }) { + const statuses = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/status', + }); + const needle = propsValue.nameContains?.trim().toLowerCase() ?? ''; + const items = needle.length === 0 ? statuses : statuses.filter((status) => String(status['name'] ?? '').toLowerCase().includes(needle)); + return jiraAiHelpers.toList({ items }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-user-groups.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-user-groups.ts new file mode 100644 index 000000000000..81ae671dc9d4 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-user-groups.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { userGroupsOutputSchema } from '../../output-schemas'; +export const getUserGroupsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_user_groups', + classification: 'SEARCH', + displayName: 'Get User Groups', + description: 'Lists the groups a user belongs to.', + audience: 'ai', + aiMetadata: { + description: + 'List the groups one user belongs to, with each group name and group ID. Use it to check group membership or to get group IDs for Send Issue Notification. Needs the Browse users and groups permission. Read-only.', + idempotent: true, + }, + outputSchema: userGroupsOutputSchema, + props: { + accountId: jiraAiProps.accountId(), + }, + async run({ auth, propsValue }) { + const groups = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: '/user/groups', + query: { accountId: propsValue.accountId.trim() }, + }); + return jiraAiHelpers.toList({ items: groups }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-worklogs.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-worklogs.ts new file mode 100644 index 000000000000..75cc1b65c58c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/get-worklogs.ts @@ -0,0 +1,75 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import dayjs from 'dayjs'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { worklogPageOutputSchema } from '../../output-schemas'; +export const getWorklogsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'get_worklogs', + classification: 'SEARCH', + displayName: 'Get Worklogs', + description: 'Lists the worklogs of an issue.', + audience: 'ai', + aiMetadata: { + description: + 'List the worklog entries of an issue, oldest first, with author, time spent in seconds, start time and comment, optionally limited to work started within a date range. Use it for time reports or to find a worklog ID for Delete Worklog. Read-only.', + idempotent: true, + }, + outputSchema: worklogPageOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + startedAfter: Property.DateTime({ + displayName: 'Started After', + description: 'Only return worklogs started on or after this time (ISO 8601).', + required: false, + }), + startedBefore: Property.DateTime({ + displayName: 'Started Before', + description: 'Only return worklogs started on or before this time (ISO 8601).', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 5000 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/worklog`, + query: { + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + startedAfter: toEpochMillis({ value: propsValue.startedAfter, label: 'Started After' }), + startedBefore: toEpochMillis({ value: propsValue.startedBefore, label: 'Started Before' }), + }, + }); + return jiraAiHelpers.toPage({ + items: response.worklogs ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + }); + }, +}); + +function toEpochMillis({ value, label }: { value: string | undefined; label: string }): number | undefined { + if (!jiraAiHelpers.isProvided(value)) { + return undefined; + } + const parsed = dayjs(value); + if (!parsed.isValid()) { + throw new Error(`${label} must be a valid ISO 8601 date-time.`); + } + return parsed.valueOf(); +} + +type WorklogPage = { + worklogs?: JiraRecord[]; + startAt?: number; + maxResults?: number; + total?: number; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-boards.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-boards.ts new file mode 100644 index 000000000000..26f74fd0c8b3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-boards.ts @@ -0,0 +1,70 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { boardsOutputSchema } from '../../output-schemas'; +export const listBoardsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'list_boards', + classification: 'SEARCH', + displayName: 'List Boards', + description: 'Lists the Scrum and Kanban boards visible to the user.', + audience: 'ai', + aiMetadata: { + description: + 'List the Jira Software boards visible to the connected user, optionally filtered by name, type (scrum or kanban) and project, with their numeric board IDs. Board IDs are required by List Sprints and Create Sprint. Requires Jira Software on the site. Read-only.', + idempotent: true, + }, + outputSchema: boardsOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Only return boards whose name contains this text.', + required: false, + }), + type: Property.StaticDropdown({ + displayName: 'Board Type', + required: false, + options: { + options: [ + { label: 'Scrum', value: 'scrum' }, + { label: 'Kanban', value: 'kanban' }, + ], + }, + }), + projectKeyOrId: Property.ShortText({ + displayName: 'Project ID or Key', + description: 'Only return boards that include this project.', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const { body: response } = await httpClient.sendRequest>({ + method: HttpMethod.GET, + url: `${auth.props.instanceUrl}/rest/agile/1.0/board`, + queryParams: { + startAt: String(propsValue.startAt ?? 0), + maxResults: String(propsValue.maxResults ?? 50), + ...(jiraAiHelpers.isProvided(propsValue.name) ? { name: propsValue.name } : {}), + ...(jiraAiHelpers.isProvided(propsValue.type) ? { type: propsValue.type } : {}), + ...(jiraAiHelpers.isProvided(propsValue.projectKeyOrId) ? { projectKeyOrId: propsValue.projectKeyOrId.trim() } : {}), + }, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-comments.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-comments.ts new file mode 100644 index 000000000000..82ef1fc10b2c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-comments.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { commentPageOutputSchema } from '../../output-schemas'; +export const listCommentsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'list_comments', + classification: 'SEARCH', + displayName: 'List Comments', + description: 'Lists the comments on an issue.', + audience: 'ai', + aiMetadata: { + description: + 'List the comments on an issue, oldest or newest first, with each comment ID, author, timestamps and body as Atlassian Document Format plus rendered HTML. Use it to read a discussion or to find comment IDs for Update Comment and Delete Comment. Paginated with Start At. Read-only.', + idempotent: true, + }, + outputSchema: commentPageOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + orderBy: Property.StaticDropdown({ + displayName: 'Order', + required: false, + defaultValue: 'created', + options: { + options: [ + { label: 'Oldest first', value: 'created' }, + { label: 'Newest first', value: '-created' }, + ], + }, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 5000 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/comment`, + query: { + orderBy: propsValue.orderBy, + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + expand: 'renderedBody', + }, + }); + return jiraAiHelpers.toPage({ + items: response.comments ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + }); + }, +}); + +type CommentPage = { + comments?: JiraRecord[]; + startAt?: number; + maxResults?: number; + total?: number; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-projects.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-projects.ts new file mode 100644 index 000000000000..7b57e7a04e2c --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-projects.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { projectPageOutputSchema } from '../../output-schemas'; +export const listProjectsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'list_projects', + classification: 'SEARCH', + displayName: 'List Projects', + description: 'Lists or searches the projects visible to the user.', + audience: 'ai', + aiMetadata: { + description: + 'List the projects visible to the connected user, optionally filtered by text in the project name or key and by project type, with each project ID, key, name and type. The starting point for resolving a project name to the key or ID that other actions need. Paginated with Start At. Read-only.', + idempotent: true, + }, + outputSchema: projectPageOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: 'Only return projects whose name or key contains this text (case-insensitive).', + required: false, + }), + typeKey: Property.StaticDropdown({ + displayName: 'Project Type', + required: false, + options: { + options: [ + { label: 'Software', value: 'software' }, + { label: 'Business', value: 'business' }, + { label: 'Service Management', value: 'service_desk' }, + { label: 'Product Discovery', value: 'product_discovery' }, + ], + }, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 100 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: '/project/search', + query: { + query: propsValue.query, + typeKey: propsValue.typeKey, + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + expand: 'description,lead', + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-sprints.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-sprints.ts new file mode 100644 index 000000000000..522b85446f1d --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/list-sprints.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { sprintsOutputSchema } from '../../output-schemas'; +export const listSprintsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'list_sprints', + classification: 'SEARCH', + displayName: 'List Sprints', + description: 'Lists the sprints of a Scrum board.', + audience: 'ai', + aiMetadata: { + description: + 'List the sprints of one Scrum board, optionally filtered by state (future, active, closed), with their numeric IDs, dates and goals. Use it to find the sprint ID for Move Issues to Sprint; Kanban boards have no sprints and return an error. Board IDs come from List Boards. Read-only.', + idempotent: true, + }, + outputSchema: sprintsOutputSchema, + props: { + boardId: Property.Number({ + displayName: 'Board ID', + description: 'The numeric board ID. Find it with List Boards.', + required: true, + }), + state: Property.StaticMultiSelectDropdown({ + displayName: 'State', + required: false, + options: { + options: [ + { label: 'Future', value: 'future' }, + { label: 'Active', value: 'active' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults(), + }, + async run({ auth, propsValue }) { + const state = propsValue.state ?? []; + const { body: response } = await httpClient.sendRequest>({ + method: HttpMethod.GET, + url: `${auth.props.instanceUrl}/rest/agile/1.0/board/${propsValue.boardId}/sprint`, + queryParams: { + startAt: String(propsValue.startAt ?? 0), + maxResults: String(propsValue.maxResults ?? 50), + ...(state.length > 0 ? { state: state.join(',') } : {}), + }, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/move-issues-to-sprint.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/move-issues-to-sprint.ts new file mode 100644 index 000000000000..7de9284ed11a --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/move-issues-to-sprint.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraAiHelpers } from '../../common/ai-helpers'; + +import { moveIssuesToSprintOutputSchema } from '../../output-schemas'; +export const moveIssuesToSprintAiAction = createAction({ + auth: jiraCloudAuth, + name: 'move_issues_to_sprint', + classification: 'WRITE', + displayName: 'Move Issues to Sprint', + description: 'Moves up to 50 issues into a sprint.', + audience: 'ai', + aiMetadata: { + description: + 'Move up to 50 issues into an open (future or active) sprint, taking them out of the backlog or any other sprint. Closed sprints are rejected. Sprint IDs come from List Sprints. Idempotent: moving issues already in the sprint leaves them there.', + idempotent: true, + }, + outputSchema: moveIssuesToSprintOutputSchema, + props: { + sprintId: Property.Number({ + displayName: 'Sprint ID', + description: 'The numeric ID of a future or active sprint. Find it with List Sprints.', + required: true, + }), + issues: Property.Array({ + displayName: 'Issue Keys', + description: 'Up to 50 issue keys (e.g. PROJ-123) or numeric issue IDs.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const issues = jiraAiHelpers.toStringList({ value: propsValue.issues }); + if (issues.length === 0 || issues.length > MAX_ISSUES) { + throw new Error(`Issue Keys must contain between 1 and ${MAX_ISSUES} items.`); + } + await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${auth.props.instanceUrl}/rest/agile/1.0/sprint/${propsValue.sprintId}/issue`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.email, + password: auth.props.apiToken, + }, + body: { issues }, + }); + return { success: true, sprint_id: propsValue.sprintId, issues, count: issues.length }; + }, +}); + +const MAX_ISSUES = 50; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/parse-jql.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/parse-jql.ts new file mode 100644 index 000000000000..93ab9070abb3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/parse-jql.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { parseJqlOutputSchema } from '../../output-schemas'; +export const parseJqlAiAction = createAction({ + auth: jiraCloudAuth, + name: 'parse_jql', + classification: 'READ', + displayName: 'Parse JQL', + description: 'Validates JQL queries and returns their structure.', + audience: 'ai', + aiMetadata: { + description: + 'Validate one or more JQL queries and return, for each, its parsed structure plus any errors or warnings such as unknown fields or values. Use it to check a generated query before Search Issues by JQL or Count Issues. Read-only.', + idempotent: true, + }, + outputSchema: parseJqlOutputSchema, + props: { + queries: Property.Array({ + displayName: 'Queries', + description: 'JQL queries to parse.', + required: true, + }), + validation: Property.StaticDropdown({ + displayName: 'Validation', + description: 'Strict reports unknown fields and values as errors, warn reports them as warnings, none only checks syntax.', + required: false, + defaultValue: 'strict', + options: { + options: [ + { label: 'Strict', value: 'strict' }, + { label: 'Warn', value: 'warn' }, + { label: 'None', value: 'none' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + const queries = jiraAiHelpers.toStringList({ value: propsValue.queries }); + if (queries.length === 0) { + throw new Error('Provide at least one JQL query.'); + } + const response = await jiraApiCall<{ queries?: JiraRecord[] }>({ + auth, + method: HttpMethod.POST, + resourceUri: '/jql/parse', + query: { validation: propsValue.validation ?? 'strict' }, + body: { queries }, + }); + return jiraAiHelpers.toList({ items: response.queries ?? [] }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/remove-watcher.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/remove-watcher.ts new file mode 100644 index 000000000000..f8c0fdf63ac3 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/remove-watcher.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiProps } from '../../common/ai-props'; + +import { watcherChangeOutputSchema } from '../../output-schemas'; +export const removeWatcherAiAction = createAction({ + auth: jiraCloudAuth, + name: 'remove_watcher', + classification: 'WRITE', + displayName: 'Remove Watcher', + description: 'Removes a user from the watchers of an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Remove one user, by account ID, from the watcher list of an issue so they stop receiving its notifications. Removing another user needs the Manage watcher list permission. Not idempotent: a retry fails when the user is no longer watching.', + idempotent: false, + }, + outputSchema: watcherChangeOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + accountId: jiraAiProps.accountId({ description: 'Account ID of the watcher to remove. Find it with Get Issue Watchers.' }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const accountId = propsValue.accountId.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.DELETE, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/watchers`, + query: { accountId }, + }); + return { success: true, issue: issueIdOrKey, account_id: accountId }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-dashboards.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-dashboards.ts new file mode 100644 index 000000000000..15712835ac0f --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-dashboards.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { dashboardsOutputSchema } from '../../output-schemas'; +export const searchDashboardsAiAction = createAction({ + auth: jiraCloudAuth, + name: 'search_dashboards', + classification: 'SEARCH', + displayName: 'Search Dashboards', + description: 'Searches the dashboards visible to the user.', + audience: 'ai', + aiMetadata: { + description: + 'Search the dashboards visible to the connected user by name or owner, returning each dashboard ID, name, owner and view URL. Read-only.', + idempotent: true, + }, + outputSchema: dashboardsOutputSchema, + props: { + dashboardName: Property.ShortText({ + displayName: 'Dashboard Name', + description: 'Only return dashboards whose name contains this text (case-insensitive).', + required: false, + }), + ownerAccountId: Property.ShortText({ + displayName: 'Owner Account ID', + description: 'Only return dashboards owned by this user. Resolve it with Find Users.', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 100 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: '/dashboard/search', + query: { + dashboardName: propsValue.dashboardName, + accountId: propsValue.ownerAccountId, + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + expand: 'description,owner,viewUrl,favourite', + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-filters.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-filters.ts new file mode 100644 index 000000000000..104cf87f863b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-filters.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraPageBean, JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { filterPageOutputSchema } from '../../output-schemas'; +export const searchFiltersAiAction = createAction({ + auth: jiraCloudAuth, + name: 'search_filters', + classification: 'SEARCH', + displayName: 'Search Filters', + description: 'Searches the saved filters visible to the user.', + audience: 'ai', + aiMetadata: { + description: + 'Search the saved filters visible to the connected user by name or owner, returning each filter ID, name, owner, JQL and view URL. Use the returned JQL with Search Issues by JQL, or the filter ID with Create Board; for only the user\'s starred filters use Get Favorite Filters. Read-only.', + idempotent: true, + }, + outputSchema: filterPageOutputSchema, + props: { + filterName: Property.ShortText({ + displayName: 'Filter Name', + description: 'Only return filters whose name contains this text (case-insensitive).', + required: false, + }), + ownerAccountId: Property.ShortText({ + displayName: 'Owner Account ID', + description: 'Only return filters owned by this user. Resolve it with Find Users.', + required: false, + }), + startAt: jiraAiProps.startAt(), + maxResults: jiraAiProps.maxResults({ max: 100 }), + }, + async run({ auth, propsValue }) { + const response = await jiraApiCall>({ + auth, + method: HttpMethod.GET, + resourceUri: '/filter/search', + query: { + filterName: propsValue.filterName, + accountId: propsValue.ownerAccountId, + startAt: propsValue.startAt, + maxResults: propsValue.maxResults, + expand: 'description,owner,jql,viewUrl,favourite', + }, + }); + return jiraAiHelpers.toPage({ + items: response.values ?? [], + startAt: response.startAt, + maxResults: response.maxResults, + total: response.total, + isLast: response.isLast, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-issues-by-jql.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-issues-by-jql.ts new file mode 100644 index 000000000000..289e0955005d --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/search-issues-by-jql.ts @@ -0,0 +1,77 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; + +import { searchIssuesByJqlOutputSchema } from '../../output-schemas'; +export const searchIssuesByJqlAiAction = createAction({ + auth: jiraCloudAuth, + name: 'search_issues_by_jql', + classification: 'SEARCH', + displayName: 'Search Issues by JQL', + description: 'Searches issues with a JQL query, one page at a time.', + audience: 'ai', + aiMetadata: { + description: + 'Search issues with a JQL query (e.g. project = PROJ AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC) and return one page of up to 100 issues with a lean default field set. Pass the returned next_page_token to get the next page; for just a total use Count Issues. JQL must be bounded by at least one restriction. Read-only.', + idempotent: true, + }, + outputSchema: searchIssuesByJqlOutputSchema, + props: { + jql: Property.LongText({ + displayName: 'JQL', + description: 'The JQL query. Use single quotes or escaped double quotes around values with spaces.', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field IDs to return. Defaults to summary, status, assignee, reporter, priority, issuetype, project, created, updated. Use *all for every field.', + required: false, + }), + maxResults: Property.Number({ + displayName: 'Max Results', + description: 'Issues per page (1-100).', + required: false, + defaultValue: 50, + }), + nextPageToken: Property.ShortText({ + displayName: 'Next Page Token', + description: 'The next_page_token from a previous call, to fetch the following page.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const maxResults = propsValue.maxResults ?? 50; + if (maxResults < 1 || maxResults > 100) { + throw new Error('Max Results must be between 1 and 100.'); + } + const fields = jiraAiHelpers.toStringList({ value: propsValue.fields }); + const response = await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: '/search/jql', + body: { + jql: propsValue.jql, + maxResults, + fields: fields.length > 0 ? fields : DEFAULT_FIELDS, + ...(jiraAiHelpers.isProvided(propsValue.nextPageToken) ? { nextPageToken: propsValue.nextPageToken.trim() } : {}), + }, + }); + const issues = response.issues ?? []; + return { + items: issues, + count: issues.length, + next_page_token: response.nextPageToken ?? null, + is_last: response.isLast ?? response.nextPageToken === undefined, + }; + }, +}); + +const DEFAULT_FIELDS = ['summary', 'status', 'assignee', 'reporter', 'priority', 'issuetype', 'project', 'created', 'updated']; + +type SearchResponse = { + issues?: JiraRecord[]; + nextPageToken?: string; + isLast?: boolean; +}; diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/send-issue-notification.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/send-issue-notification.ts new file mode 100644 index 000000000000..81d915e9622d --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/send-issue-notification.ts @@ -0,0 +1,80 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +export const sendIssueNotificationAiAction = createAction({ + auth: jiraCloudAuth, + name: 'send_issue_notification', + classification: 'WRITE', + displayName: 'Send Issue Notification', + description: 'Sends an email notification about an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Send a custom email notification about an issue to its reporter, assignee, watchers, voters, specific users (by account ID) or groups (by group ID). At least one recipient must be selected. Jira queues the email, so delivery is not confirmed. Not idempotent: every call sends another email.', + idempotent: false, + }, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + subject: Property.ShortText({ + displayName: 'Subject', + description: 'Email subject. Defaults to the issue key and summary.', + required: false, + }), + textBody: Property.LongText({ + displayName: 'Text Body', + description: 'Plain-text body of the email.', + required: true, + }), + htmlBody: Property.LongText({ + displayName: 'HTML Body', + description: 'Optional HTML body of the email.', + required: false, + }), + notifyReporter: Property.Checkbox({ displayName: 'Notify Reporter', required: false, defaultValue: false }), + notifyAssignee: Property.Checkbox({ displayName: 'Notify Assignee', required: false, defaultValue: false }), + notifyWatchers: Property.Checkbox({ displayName: 'Notify Watchers', required: false, defaultValue: false }), + notifyVoters: Property.Checkbox({ displayName: 'Notify Voters', required: false, defaultValue: false }), + userAccountIds: Property.Array({ + displayName: 'User Account IDs', + description: 'Account IDs of additional users to notify. Resolve them with Find Users.', + required: false, + }), + groupIds: Property.Array({ + displayName: 'Group IDs', + description: 'IDs of groups to notify. Find them with Get User Groups.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const userAccountIds = jiraAiHelpers.toStringList({ value: propsValue.userAccountIds }); + const groupIds = jiraAiHelpers.toStringList({ value: propsValue.groupIds }); + const to = { + reporter: propsValue.notifyReporter === true, + assignee: propsValue.notifyAssignee === true, + watchers: propsValue.notifyWatchers === true, + voters: propsValue.notifyVoters === true, + users: userAccountIds.map((accountId) => ({ accountId })), + groupIds, + }; + if (!to.reporter && !to.assignee && !to.watchers && !to.voters && userAccountIds.length === 0 && groupIds.length === 0) { + throw new Error('Select at least one recipient: reporter, assignee, watchers, voters, users or groups.'); + } + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/notify`, + body: { + textBody: propsValue.textBody, + to, + ...(jiraAiHelpers.isProvided(propsValue.subject) ? { subject: propsValue.subject } : {}), + ...(jiraAiHelpers.isProvided(propsValue.htmlBody) ? { htmlBody: propsValue.htmlBody } : {}), + }, + }); + return { success: true, issue: issueIdOrKey, queued: true }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/transition-issue-status.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/transition-issue-status.ts new file mode 100644 index 000000000000..1eae423db244 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/transition-issue-status.ts @@ -0,0 +1,72 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { transitionIssueStatusOutputSchema } from '../../output-schemas'; +export const transitionIssueStatusAiAction = createAction({ + auth: jiraCloudAuth, + name: 'transition_issue_status', + classification: 'WRITE', + displayName: 'Transition Issue Status', + description: 'Moves an issue through a workflow transition.', + audience: 'ai', + aiMetadata: { + description: + 'Move an issue to a new status by performing a workflow transition, optionally setting a resolution, other screen fields and a comment. Needs the numeric transition ID from Get Issue Transitions, which also shows the fields a transition requires. Not idempotent: once moved, the same transition is usually no longer available.', + idempotent: false, + }, + outputSchema: transitionIssueStatusOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + transitionId: Property.ShortText({ + displayName: 'Transition ID', + description: 'The numeric transition ID. Find it with Get Issue Transitions.', + required: true, + }), + resolutionId: Property.ShortText({ + displayName: 'Resolution ID', + description: 'Resolution to set when the transition screen asks for one. Find it with Get Issue Resolutions.', + required: false, + }), + comment: jiraAiProps.optionalMarkdownText({ displayName: 'Comment', description: 'Optional comment added with the transition.' }), + fields: Property.Json({ + displayName: 'Additional Fields', + description: 'JSON object of other transition screen field IDs to values, e.g. {"customfield_10020": "value"}.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const issueIdOrKey = propsValue.issueIdOrKey.trim(); + const fields = { + ...(jiraAiHelpers.isProvided(propsValue.resolutionId) ? { resolution: { id: propsValue.resolutionId.trim() } } : {}), + ...(jiraAiHelpers.toRecord({ value: propsValue.fields, label: 'Additional Fields' }) ?? {}), + }; + await jiraApiCall({ + auth, + method: HttpMethod.POST, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}/transitions`, + body: { + transition: { id: propsValue.transitionId.trim() }, + ...(Object.keys(fields).length > 0 ? { fields } : {}), + ...(jiraAiHelpers.isProvided(propsValue.comment) + ? { update: { comment: [{ add: { body: jiraAiHelpers.markdownToAdf({ markdown: propsValue.comment }) } }] } } + : {}), + }, + }); + const issue = await jiraApiCall<{ key?: string; fields?: { status?: JiraRecord } }>({ + auth, + method: HttpMethod.GET, + resourceUri: `/issue/${encodeURIComponent(issueIdOrKey)}`, + query: { fields: 'status' }, + }); + return { + success: true, + issue: issue.key ?? issueIdOrKey, + transition_id: propsValue.transitionId.trim(), + status: issue.fields?.status ?? null, + }; + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/update-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/update-comment.ts new file mode 100644 index 000000000000..e69deb986fd7 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/update-comment.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { jiraCloudAuth } from '../../../auth'; +import { jiraApiCall } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { commentOutputSchema } from '../../output-schemas'; +export const updateCommentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'update_comment', + classification: 'WRITE', + displayName: 'Update Comment', + description: 'Replaces the text of a comment.', + audience: 'ai', + aiMetadata: { + description: + 'Replace the text of an existing comment on an issue with new markdown text; the comment keeps its author and visibility. Comment IDs come from List Comments, and only the author or a user with edit-all-comments permission may edit. Idempotent: writing the same text again changes nothing.', + idempotent: true, + }, + outputSchema: commentOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + commentId: Property.ShortText({ + displayName: 'Comment ID', + description: 'The numeric comment ID. Find it with List Comments.', + required: true, + }), + comment: jiraAiProps.markdownText({ displayName: 'Comment', description: 'The new comment text.' }), + notifyUsers: Property.Checkbox({ + displayName: 'Notify Watchers', + required: false, + defaultValue: true, + }), + }, + async run({ auth, propsValue }) { + return jiraApiCall({ + auth, + method: HttpMethod.PUT, + resourceUri: `/issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/comment/${encodeURIComponent(propsValue.commentId.trim())}`, + query: { notifyUsers: String(propsValue.notifyUsers !== false) }, + body: { body: jiraAiHelpers.markdownToAdf({ markdown: propsValue.comment }) }, + }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/ai/upload-attachment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/ai/upload-attachment.ts new file mode 100644 index 000000000000..67e35d33288b --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/actions/ai/upload-attachment.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { jiraCloudAuth } from '../../../auth'; +import { sendJiraRequest } from '../../common'; +import { JiraRecord, jiraAiHelpers } from '../../common/ai-helpers'; +import { jiraAiProps } from '../../common/ai-props'; + +import { uploadAttachmentOutputSchema } from '../../output-schemas'; +export const uploadAttachmentAiAction = createAction({ + auth: jiraCloudAuth, + name: 'upload_attachment', + classification: 'WRITE', + displayName: 'Upload Attachment', + description: 'Attaches a file to an issue.', + audience: 'ai', + aiMetadata: { + description: + 'Upload a file and attach it to an issue, returning the new attachment ID, name, size and content URL. Attachments must be enabled and the file within the site size limit. Not idempotent: each call adds another copy of the file.', + idempotent: false, + }, + outputSchema: uploadAttachmentOutputSchema, + props: { + issueIdOrKey: jiraAiProps.issueIdOrKey(), + file: Property.File({ + displayName: 'File', + required: true, + }), + fileName: Property.ShortText({ + displayName: 'File Name', + description: 'Name to give the attachment, including extension. Defaults to the original file name.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + const fileName = jiraAiHelpers.isProvided(propsValue.fileName) ? propsValue.fileName.trim() : propsValue.file.filename; + formData.append('file', Buffer.from(propsValue.file.base64, 'base64'), fileName); + const response = await sendJiraRequest({ + auth, + method: HttpMethod.POST, + url: `issue/${encodeURIComponent(propsValue.issueIdOrKey.trim())}/attachments`, + headers: { 'X-Atlassian-Token': 'no-check', ...formData.getHeaders() }, + body: formData, + }); + const attachments: JiraRecord[] = response.body; + return jiraAiHelpers.toList({ items: attachments }); + }, +}); diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/assign-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/assign-issue.ts index a3f318eb824b..3190e5362b25 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/assign-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/assign-issue.ts @@ -9,7 +9,7 @@ export const assignIssueAction = createAction({ name: 'assign_issue', displayName: 'Assign Issue', description: 'Assigns an issue to a user.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Set the assignee of an existing Jira issue to a chosen user, replacing any current assignee. Use to route ownership of an issue; it changes only the assignee field, nothing else. Idempotent: assigning the same user again leaves the issue unchanged.', diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/create-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/create-issue.ts index 9c81e09ab797..2aef200e7568 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/create-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/create-issue.ts @@ -13,6 +13,7 @@ import { IssueFieldMetaData, VALID_CUSTOM_FIELD_TYPES } from '../common/types'; import { HttpMethod } from '@activepieces/pieces-common'; import { isNil } from '@activepieces/pieces-framework'; +import { issueOutputSchema } from '../output-schemas'; async function getFields(auth: JiraAuth, projectId: string, issueTypeId: string): Promise { const fields = await jiraPaginatedApiCall({ auth: auth, @@ -32,13 +33,14 @@ export const createIssueAction = createAction({ name: 'create_issue', displayName: 'Create Issue', description: 'Creates a new issue in a project.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new Jira issue in a given project and issue type, with fields driven by that type\'s create screen (summary, description, custom fields, etc.); selected rich-text fields can be supplied as raw ADF JSON. Use for brand-new work items — to modify an existing one use Update Issue. Not idempotent: every run creates another issue.', idempotent: false, }, auth: jiraCloudAuth, + outputSchema: issueOutputSchema, props: { projectId: getProjectIdDropdown(), issueTypeId: issueTypeIdProp('Issue Type'), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/delete-issue-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/delete-issue-comment.ts index e8cfb8f20c57..93674f8e211b 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/delete-issue-comment.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/delete-issue-comment.ts @@ -9,7 +9,7 @@ export const deleteIssueCommentAction = createAction({ name: 'delete_issue_comment', displayName: 'Delete Issue Comment', description: 'Deletes a comment on a specific issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Permanently remove a specific comment (by comment ID) from a Jira issue. Use to clean up an outdated or incorrect comment; the deletion cannot be undone. Idempotent in effect — the comment ends up gone either way — though a repeat call on an already-deleted comment returns a not-found error.', diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/find-user.ts b/packages/pieces/community/jira-cloud/src/lib/actions/find-user.ts index c9ded709ff19..bff36daf0bff 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/find-user.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/find-user.ts @@ -3,17 +3,19 @@ import { jiraCloudAuth } from "../../auth"; import { jiraApiCall } from "../common"; import { HttpMethod } from "@activepieces/pieces-common"; +import { legacyFindUserOutputSchema } from '../output-schemas'; export const findUserAction = createAction({ auth:jiraCloudAuth, name:'find-user', displayName:'Find User', description:'Finds an existing user.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Search Jira users by a keyword matched against display name and email, returning all matches plus a found flag. Use to resolve a person\'s name or email into a Jira accountId before assigning issues or adding watchers. Read-only and idempotent.', idempotent: true, }, + outputSchema: legacyFindUserOutputSchema, props:{ keyword:Property.ShortText({ displayName:'Keyword', diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/get-issue-attachment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/get-issue-attachment.ts index 5430d6347b62..29127d3bfa38 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/get-issue-attachment.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/get-issue-attachment.ts @@ -3,17 +3,19 @@ import { jiraCloudAuth } from "../../auth"; import { jiraApiCall } from "../common"; import { AuthenticationType, httpClient, HttpMethod } from "@activepieces/pieces-common"; +import { legacyGetAttachmentOutputSchema } from '../output-schemas'; export const getIssueAttachmentAction = createAction({ auth: jiraCloudAuth, name: 'get-issue-attachment', displayName: 'Get Issue Attachment', description: 'Retrieves an attachment from an issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Download a Jira attachment by its attachment ID, fetching both its metadata and the file content itself for use in later steps. Use when you already have the attachment ID (e.g. from Get Issue) and need the actual file, not just a link. Read-only and idempotent.', idempotent: true, }, + outputSchema: legacyGetAttachmentOutputSchema, props: { attachmentId: Property.ShortText({ displayName: 'Attachment ID', diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/get-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/get-issue.ts index eaf92f886a66..6c4c83b9bfd7 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/get-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/get-issue.ts @@ -4,17 +4,19 @@ import { HttpMethod, QueryParams } from '@activepieces/pieces-common'; import { mapFieldNames, sendJiraRequest } from '../common'; import { getIssueIdDropdown, getProjectIdDropdown } from '../common/props'; +import { issueOutputSchema } from '../output-schemas'; export const getIssueAction = createAction({ auth: jiraCloudAuth, name: 'get_issue', displayName: 'Get Issue', description: 'Get issue data.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetch a single Jira issue by project and issue, optionally expanding extras like rendered fields, transitions, edit metadata, or changelog, and optionally remapping cryptic field/transition IDs to human-readable names. Use when you know which issue you want; to find issues by criteria use Search Issues instead. Read-only and idempotent.', idempotent: true, }, + outputSchema: issueOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/link-issues.ts b/packages/pieces/community/jira-cloud/src/lib/actions/link-issues.ts index 5e32aecee6bc..6404ca0dcdef 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/link-issues.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/link-issues.ts @@ -5,17 +5,19 @@ import { isNil } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { jiraApiCall } from '../common'; +import { legacyLinkIssuesOutputSchema } from '../output-schemas'; export const linkIssuesAction = createAction({ auth: jiraCloudAuth, name: 'link-issues', displayName: 'Link Issues', description: 'Creates a link between two issues.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a directional link of a chosen type (e.g. blocks, relates to, duplicates) between two existing Jira issues, given both issue IDs/keys. Use to express dependencies or relationships; direction matters, with the first issue taking the outward role. Not idempotent: re-running can add a duplicate link.', idempotent: false, }, + outputSchema: legacyLinkIssuesOutputSchema, props: { firstIssueId: issueIdOrKeyProp('First Issue', true), issueLinkTypeId: issueLinkTypeIdProp('Link Type', true), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/list-issue-comments.ts b/packages/pieces/community/jira-cloud/src/lib/actions/list-issue-comments.ts index 245325d5926a..4b03fcd23270 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/list-issue-comments.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/list-issue-comments.ts @@ -4,17 +4,19 @@ import { HttpMethod } from '@activepieces/pieces-common'; import { sendJiraRequest } from '../common'; import { getProjectIdDropdown, getIssueIdDropdown } from '../common/props'; +import { legacyCommentPageOutputSchema } from '../output-schemas'; export const listIssueCommentsAction = createAction({ auth: jiraCloudAuth, name: 'list_issue_comments', displayName: 'List Issue Comments', description: 'Returns all comments for an issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'List comments on a Jira issue, ordered by creation date (ascending or descending) and capped at a configurable limit, including rendered HTML bodies. Use to read an issue\'s discussion before replying or summarizing. Read-only and idempotent.', idempotent: true, }, + outputSchema: legacyCommentPageOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/markdown-to-jira-format.ts b/packages/pieces/community/jira-cloud/src/lib/actions/markdown-to-jira-format.ts index 5fcfab04a3c6..56b3d2aaa103 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/markdown-to-jira-format.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/markdown-to-jira-format.ts @@ -3,6 +3,7 @@ import { defaultSchema } from '@atlaskit/adf-schema/schema-default'; import { JSONTransformer } from '@atlaskit/editor-json-transformer'; import { MarkdownTransformer } from '@atlaskit/editor-markdown-transformer'; +import { markdownToAdfOutputSchema } from '../output-schemas'; export const markdownToJiraFormat = createAction({ name: 'markdownToJiraFormat', displayName: 'Markdown to Jira format', @@ -15,6 +16,7 @@ export const markdownToJiraFormat = createAction({ idempotent: true, }, requireAuth: false, + outputSchema: markdownToAdfOutputSchema, props: { markdown: Property.LongText({ displayName: 'Markdown text', diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/search-issues.ts b/packages/pieces/community/jira-cloud/src/lib/actions/search-issues.ts index 75174e338fcd..8836f95a1fa4 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/search-issues.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/search-issues.ts @@ -5,17 +5,19 @@ import { searchIssuesByJql, mapFieldNames } from '../common'; import * as z from 'zod/mini' import { propsValidation } from '@activepieces/pieces-common'; +import { issueListOutputSchema } from '../output-schemas'; export const searchIssues = createAction({ name: 'search_issues', displayName: 'Search Issues', description: 'Search for issues with JQL', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Search Jira issues with an arbitrary JQL query, paginating automatically up to a max-results cap (1-5000) and optionally restricting which fields are returned or mapping field IDs to readable names. The go-to tool for finding issues by project, status, assignee, dates, or any JQL-expressible criteria; limit returned fields on large result sets to avoid memory issues. Read-only and idempotent.', idempotent: true, }, auth: jiraCloudAuth, + outputSchema: issueListOutputSchema, props: { memoryWarning: Property.MarkDown({ value: `Fetching a large number of issues without specifying **Fields to Return** may exceed your flow's memory limits. Use field selection to reduce payload size.`, diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/transition-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/transition-issue.ts index 02191c9a106d..dddce66352c8 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/transition-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/transition-issue.ts @@ -9,18 +9,20 @@ import { issueStatusIdProp, } from '../common/props'; +import { legacyTransitionIssueOutputSchema } from '../output-schemas'; export const transitionIssueAction = createAction({ auth: jiraCloudAuth, name: 'transition_issue', displayName: 'Transition Issue', description: 'Moves an issue to a new status by executing a workflow transition.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Execute a workflow transition on a Jira issue to move it to a new status (e.g. In Progress to Done), returning the refreshed issue. The transition must be valid from the issue\'s current status under its workflow, so it is not idempotent: re-running after the move succeeds fails because the transition is no longer available.', idempotent: false, }, + outputSchema: legacyTransitionIssueOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/update-issue-comment.ts b/packages/pieces/community/jira-cloud/src/lib/actions/update-issue-comment.ts index 34710f8ddb6a..cdadfce899fa 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/update-issue-comment.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/update-issue-comment.ts @@ -4,17 +4,19 @@ import { HttpMethod } from '@activepieces/pieces-common'; import { sendJiraRequest } from '../common'; import { getIssueIdDropdown, getProjectIdDropdown } from '../common/props'; +import { commentOutputSchema } from '../output-schemas'; export const updateIssueCommentAction = createAction({ auth: jiraCloudAuth, name: 'update_issue_comment', displayName: 'Update Issue Comment', description: 'Updates a comment to a specific issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Replace the body of an existing comment (by comment ID) on a Jira issue, in plain text or raw ADF JSON when the ADF flag is set. Use to correct or extend an earlier comment instead of posting a new one with Add Issue Comment. Idempotent: re-running with the same body leaves the comment unchanged.', idempotent: true, }, + outputSchema: commentOutputSchema, props: { projectId: getProjectIdDropdown(), issueId: getIssueIdDropdown({ refreshers: ['projectId'] }), diff --git a/packages/pieces/community/jira-cloud/src/lib/actions/update-issue.ts b/packages/pieces/community/jira-cloud/src/lib/actions/update-issue.ts index d0959797f1b9..ce3925b55e36 100644 --- a/packages/pieces/community/jira-cloud/src/lib/actions/update-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/actions/update-issue.ts @@ -13,6 +13,7 @@ import { IssueFieldMetaData, VALID_CUSTOM_FIELD_TYPES } from '../common/types'; import { HttpMethod } from '@activepieces/pieces-common'; import { isNil } from '@activepieces/pieces-framework'; +import { issueOutputSchema } from '../output-schemas'; async function getFields(auth: JiraAuth, issueId: string): Promise { const response = await jiraApiCall<{ fields: { [x: string]: IssueFieldMetaData } }>({ auth: auth, @@ -36,13 +37,14 @@ export const updateIssueAction = createAction({ name: 'update_issue', displayName: 'Update Issue', description: 'Updates an existing issue.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Update fields on an existing Jira issue (by ID or key) using its edit screen — summary, description, custom fields, etc. — with optional ADF JSON for rich-text fields, and optionally trigger a status transition in the same run; returns the refreshed issue. Use to modify existing items rather than Create Issue. Field updates are idempotent (same values yield the same state), though a repeated status transition may fail once already applied.', idempotent: true, }, auth: jiraCloudAuth, + outputSchema: issueOutputSchema, props: { issueId: issueIdOrKeyProp('Issue ID or Key', true), statusId: issueStatusIdProp('Status', false), diff --git a/packages/pieces/community/jira-cloud/src/lib/common/ai-helpers.ts b/packages/pieces/community/jira-cloud/src/lib/common/ai-helpers.ts new file mode 100644 index 000000000000..7c92dd9a3d02 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/common/ai-helpers.ts @@ -0,0 +1,138 @@ +import { JSONTransformer } from '@atlaskit/editor-json-transformer'; +import { MarkdownTransformer } from '@atlaskit/editor-markdown-transformer'; +import { JiraAuth } from '../../auth'; + +function browseUrl({ auth, issueKey }: { auth: JiraAuth; issueKey: string }): string { + return `${auth.props.instanceUrl}/browse/${issueKey}`; +} + +function markdownToAdf({ markdown }: { markdown: string }): unknown { + const jsonTransformer = new JSONTransformer(); + const markdownTransformer = new MarkdownTransformer(); + return jsonTransformer.encode(markdownTransformer.parse(markdown)); +} + +function toPage({ items, startAt, maxResults, total, isLast }: PageInput) { + const resolvedStartAt = startAt ?? 0; + return { + items, + count: items.length, + start_at: resolvedStartAt, + max_results: maxResults ?? null, + total: total ?? null, + is_last: isLast ?? (total === undefined ? null : resolvedStartAt + items.length >= total), + }; +} + +function toList({ items }: { items: T[] }) { + return { items, count: items.length }; +} + +function projectReference({ projectIdOrKey }: { projectIdOrKey: string }) { + const trimmed = projectIdOrKey.trim(); + return /^\d+$/.test(trimmed) ? { id: trimmed } : { key: trimmed }; +} + +function buildIssueFields({ + projectIdOrKey, + issueTypeId, + summary, + description, + assigneeAccountId, + priorityId, + labels, + parentKey, + dueDate, + extraFields, +}: IssueFieldsInput): Record { + return { + ...(extraFields ?? {}), + ...(isProvided(projectIdOrKey) ? { project: projectReference({ projectIdOrKey }) } : {}), + ...(isProvided(issueTypeId) ? { issuetype: { id: issueTypeId.trim() } } : {}), + ...(isProvided(summary) ? { summary } : {}), + ...(isProvided(description) ? { description: markdownToAdf({ markdown: description }) } : {}), + ...(isProvided(assigneeAccountId) ? { assignee: { accountId: assigneeAccountId.trim() } } : {}), + ...(isProvided(priorityId) ? { priority: { id: priorityId.trim() } } : {}), + ...(labels !== undefined && labels.length > 0 ? { labels } : {}), + ...(isProvided(parentKey) ? { parent: { key: parentKey.trim() } } : {}), + ...(isProvided(dueDate) ? { duedate: dueDate.trim() } : {}), + }; +} + +function toStringList({ value }: { value: unknown }): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => (typeof item === 'string' || typeof item === 'number' ? String(item).trim() : '')) + .filter((item) => item.length > 0); +} + +function toRecord({ value, label }: { value: unknown; label: string }): Record | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (!isRecord(value)) { + throw new Error(`${label} must be a JSON object.`); + } + return value; +} + +function toRecordList({ value, label }: { value: unknown; label: string }): Record[] { + if (!Array.isArray(value) || !value.every(isRecord)) { + throw new Error(`${label} must be a JSON array of objects.`); + } + return value; +} + +function isProvided(value: string | undefined | null): value is string { + return value !== undefined && value !== null && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const jiraAiHelpers = { + browseUrl, + markdownToAdf, + toPage, + toList, + projectReference, + buildIssueFields, + toStringList, + toRecord, + toRecordList, + isProvided, +}; + +type PageInput = { + items: T[]; + startAt?: number; + maxResults?: number; + total?: number; + isLast?: boolean; +}; + +type IssueFieldsInput = { + projectIdOrKey?: string; + issueTypeId?: string; + summary?: string; + description?: string; + assigneeAccountId?: string; + priorityId?: string; + labels?: string[]; + parentKey?: string; + dueDate?: string; + extraFields?: Record; +}; + +export type JiraPageBean = { + values?: T[]; + startAt?: number; + maxResults?: number; + total?: number; + isLast?: boolean; +}; + +export type JiraRecord = Record; diff --git a/packages/pieces/community/jira-cloud/src/lib/common/ai-props.ts b/packages/pieces/community/jira-cloud/src/lib/common/ai-props.ts new file mode 100644 index 000000000000..7c515f0dd159 --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/common/ai-props.ts @@ -0,0 +1,73 @@ +import { Property } from '@activepieces/pieces-framework'; + +function issueIdOrKey({ displayName, description }: PropText = {}) { + return Property.ShortText({ + displayName: displayName ?? 'Issue ID or Key', + description: description ?? 'The issue key (e.g. PROJ-123) or numeric issue ID. Find it with Search Issues by JQL.', + required: true, + }); +} + +function projectIdOrKey({ description }: PropText = {}) { + return Property.ShortText({ + displayName: 'Project ID or Key', + description: description ?? 'The project key (e.g. PROJ) or numeric project ID. Find it with List Projects.', + required: true, + }); +} + +function accountId({ displayName, description }: PropText = {}) { + return Property.ShortText({ + displayName: displayName ?? 'Account ID', + description: description ?? 'The Atlassian account ID of the user. Resolve it with Find Users or Get Current User.', + required: true, + }); +} + +function startAt() { + return Property.Number({ + displayName: 'Start At', + description: 'Zero-based index of the first item to return, for pagination.', + required: false, + defaultValue: 0, + }); +} + +function maxResults({ defaultValue = 50, max }: { defaultValue?: number; max?: number } = {}) { + return Property.Number({ + displayName: 'Max Results', + description: max === undefined ? 'Maximum number of items to return in this page.' : `Maximum number of items to return in this page (up to ${max}).`, + required: false, + defaultValue, + }); +} + +function markdownText({ displayName, description }: { displayName: string; description: string }) { + return Property.LongText({ + displayName, + description: `${description} ${MARKDOWN_NOTE}`, + required: true, + }); +} + +function optionalMarkdownText({ displayName, description }: { displayName: string; description: string }) { + return Property.LongText({ + displayName, + description: `${description} ${MARKDOWN_NOTE}`, + required: false, + }); +} + +export const jiraAiProps = { + issueIdOrKey, + projectIdOrKey, + accountId, + startAt, + maxResults, + markdownText, + optionalMarkdownText, +}; + +const MARKDOWN_NOTE = 'Markdown is supported and converted to Atlassian Document Format.'; + +type PropText = { displayName?: string; description?: string }; diff --git a/packages/pieces/community/jira-cloud/src/lib/common/props.ts b/packages/pieces/community/jira-cloud/src/lib/common/props.ts index 6815e67ff28c..d856ef466436 100644 --- a/packages/pieces/community/jira-cloud/src/lib/common/props.ts +++ b/packages/pieces/community/jira-cloud/src/lib/common/props.ts @@ -320,23 +320,29 @@ export const issueIdOrKeyProp = (displayName: string, required = true) => placeholder: 'Please connect your account first', }; } - const authValue = auth as JiraAuth; - const response = await jiraPaginatedApiCall<{ id: string; key: string }, 'issues'>({ - auth: authValue, - resourceUri: '/search', - propertyName: 'issues', - query: { fields: 'summary' }, - method: HttpMethod.GET, - }); - const options: DropdownOption[] = []; - - for (const issue of response) { - options.push({ - value: issue.id, - label: issue.key, + let nextPageToken: string | undefined; + do { + const response = await sendJiraRequest({ + method: HttpMethod.POST, + url: 'search/jql', + auth: auth as JiraAuth, + body: { + fields: ['summary'], + jql: 'created <= now() ORDER BY updated DESC', + nextPageToken, + maxResults: 100, + }, }); - } + const issueList = response.body as SearchIssuesResponse; + options.push( + ...issueList.issues.map((issue) => ({ + value: issue.id, + label: issue.key, + })), + ); + nextPageToken = issueList.isLast ? undefined : issueList.nextPageToken; + } while (nextPageToken && options.length < ISSUE_OPTIONS_LIMIT); return { disabled: false, @@ -679,3 +685,5 @@ export function transformCustomFields( return result; } + +const ISSUE_OPTIONS_LIMIT = 1000; diff --git a/packages/pieces/community/jira-cloud/src/lib/output-schemas.ts b/packages/pieces/community/jira-cloud/src/lib/output-schemas.ts new file mode 100644 index 000000000000..de07c711847f --- /dev/null +++ b/packages/pieces/community/jira-cloud/src/lib/output-schemas.ts @@ -0,0 +1,910 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const userFields: OutputSchema['fields'] = [ + { key: 'accountId', label: 'Account ID' }, + { key: 'displayName', label: 'Display Name' }, + { key: 'emailAddress', label: 'Email', format: 'email' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'timeZone', label: 'Time Zone' }, + { key: 'accountType', label: 'Account Type' }, +]; + +const statusFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Status ID' }, + { key: 'name', label: 'Status' }, + { key: 'description', label: 'Description' }, + { + key: 'statusCategory', + label: 'Status Category', + children: [ + { key: 'key', label: 'Category Key' }, + { key: 'name', label: 'Category Name' }, + ], + }, +]; + +const attachmentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Attachment ID' }, + { key: 'filename', label: 'File Name' }, + { key: 'mimeType', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'content', label: 'Content URL', format: 'url' }, + { key: 'thumbnail', label: 'Thumbnail URL', format: 'image' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'author', label: 'Author', children: userFields }, +]; + +const versionFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Version ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'released', label: 'Released', format: 'boolean' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'overdue', label: 'Overdue', format: 'boolean' }, + { key: 'startDate', label: 'Start Date', format: 'date' }, + { key: 'releaseDate', label: 'Release Date', format: 'date' }, + { key: 'projectId', label: 'Project ID' }, +]; + +const issueVersionFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Version ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'released', label: 'Released', format: 'boolean' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'releaseDate', label: 'Release Date', format: 'date' }, +]; + +const issueFieldsFields: OutputSchema['fields'] = [ + { key: 'summary', label: 'Summary' }, + { key: 'status', label: 'Status', children: statusFields }, + { + key: 'issuetype', + label: 'Issue Type', + children: [ + { key: 'id', label: 'Issue Type ID' }, + { key: 'name', label: 'Issue Type' }, + { key: 'subtask', label: 'Subtask', format: 'boolean' }, + ], + }, + { + key: 'priority', + label: 'Priority', + children: [ + { key: 'id', label: 'Priority ID' }, + { key: 'name', label: 'Priority' }, + ], + }, + { + key: 'project', + label: 'Project', + children: [ + { key: 'id', label: 'Project ID' }, + { key: 'key', label: 'Project Key' }, + { key: 'name', label: 'Project Name' }, + ], + }, + { key: 'assignee', label: 'Assignee', children: userFields }, + { key: 'reporter', label: 'Reporter', children: userFields }, + { key: 'creator', label: 'Creator', children: userFields }, + { key: 'labels', label: 'Labels' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'updated', label: 'Updated', format: 'datetime' }, + { key: 'duedate', label: 'Due Date', format: 'date' }, + { key: 'resolutiondate', label: 'Resolved', format: 'datetime' }, + { + key: 'resolution', + label: 'Resolution', + children: [ + { key: 'id', label: 'Resolution ID' }, + { key: 'name', label: 'Resolution' }, + ], + }, + { + key: 'parent', + label: 'Parent', + children: [ + { key: 'id', label: 'Parent ID' }, + { key: 'key', label: 'Parent Key' }, + ], + }, + { key: 'description', label: 'Description (Atlassian Document Format)' }, + { key: 'fixVersions', label: 'Fix Versions', labelKey: 'name', listItems: issueVersionFields }, + { key: 'components', label: 'Components' }, + { key: 'attachment', label: 'Attachments', labelKey: 'filename', listItems: attachmentFields }, + { + key: 'issuelinks', + label: 'Issue Links', + listItems: [ + { key: 'id', label: 'Link ID' }, + { + key: 'type', + label: 'Link Type', + children: [ + { key: 'name', label: 'Name' }, + { key: 'inward', label: 'Inward Description' }, + { key: 'outward', label: 'Outward Description' }, + ], + }, + { key: 'inwardIssue', label: 'Inward Issue', children: [{ key: 'key', label: 'Issue Key' }] }, + { key: 'outwardIssue', label: 'Outward Issue', children: [{ key: 'key', label: 'Issue Key' }] }, + ], + }, + { + key: 'subtasks', + label: 'Subtasks', + labelKey: 'key', + listItems: [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + ], + }, +]; + +const issueFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + { key: 'self', label: 'API URL', format: 'url' }, + { key: 'fields', label: 'Fields', children: issueFieldsFields }, +]; + +const issueSummaryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + { key: 'summary', label: 'Summary' }, +]; + +const commentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Comment ID' }, + { key: 'author', label: 'Author', children: userFields }, + { key: 'updateAuthor', label: 'Last Updated By', children: userFields }, + { key: 'renderedBody', label: 'Body (HTML)', format: 'html' }, + { key: 'body', label: 'Body (Atlassian Document Format)' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'updated', label: 'Updated', format: 'datetime' }, + { key: 'jsdPublic', label: 'Public', format: 'boolean' }, +]; + +const worklogFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Worklog ID' }, + { key: 'issueId', label: 'Issue ID' }, + { key: 'timeSpent', label: 'Time Spent' }, + { key: 'timeSpentSeconds', label: 'Time Spent (Seconds)', format: 'number' }, + { key: 'started', label: 'Started', format: 'datetime' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'updated', label: 'Updated', format: 'datetime' }, + { key: 'author', label: 'Author', children: userFields }, + { key: 'comment', label: 'Comment (Atlassian Document Format)' }, +]; + +const projectFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Project ID' }, + { key: 'key', label: 'Project Key' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'projectTypeKey', label: 'Project Type' }, + { key: 'style', label: 'Style' }, + { key: 'simplified', label: 'Team-Managed', format: 'boolean' }, + { key: 'isPrivate', label: 'Private', format: 'boolean' }, + { + key: 'lead', + label: 'Lead', + children: [ + { key: 'accountId', label: 'Account ID' }, + { key: 'displayName', label: 'Display Name' }, + ], + }, +]; + +const issueTypeFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Issue Type ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'subtask', label: 'Subtask', format: 'boolean' }, + { key: 'hierarchyLevel', label: 'Hierarchy Level', format: 'number' }, + { key: 'iconUrl', label: 'Icon', format: 'image' }, +]; + +const priorityFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Priority ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'statusColor', label: 'Color' }, + { key: 'iconUrl', label: 'Icon', format: 'image' }, + { key: 'isDefault', label: 'Default', format: 'boolean' }, +]; + +const sprintFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Sprint ID' }, + { key: 'name', label: 'Name' }, + { key: 'state', label: 'State' }, + { key: 'goal', label: 'Goal' }, + { key: 'startDate', label: 'Start Date', format: 'datetime' }, + { key: 'endDate', label: 'End Date', format: 'datetime' }, + { key: 'completeDate', label: 'Completed', format: 'datetime' }, + { key: 'createdDate', label: 'Created', format: 'datetime' }, + { key: 'originBoardId', label: 'Board ID' }, +]; + +const filterFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Filter ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'jql', label: 'JQL' }, + { key: 'viewUrl', label: 'View URL', format: 'url' }, + { key: 'favourite', label: 'Favourite', format: 'boolean' }, + { + key: 'owner', + label: 'Owner', + children: [ + { key: 'accountId', label: 'Account ID' }, + { key: 'displayName', label: 'Display Name' }, + ], + }, +]; + +const pageMetaFields: OutputSchema['fields'] = [ + { key: 'count', label: 'Returned', format: 'number' }, + { key: 'start_at', label: 'Start At', format: 'number' }, + { key: 'max_results', label: 'Max Results', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'is_last', label: 'Last Page', format: 'boolean' }, +]; + +const countField: OutputSchema['fields'][number] = { key: 'count', label: 'Returned', format: 'number' }; + +const successFields: OutputSchema['fields'] = [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'issue', label: 'Issue' }, +]; + +export const issueOutputSchema: OutputSchema = { fields: issueFields }; + +export const issueListOutputSchema: OutputSchema = { + itemLabel: '{key}: {fields.summary}', + fields: [{ key: 'issues', label: 'Issues', value: '', listItems: issueFields }], +}; + +export const searchIssuesByJqlOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Issues', labelKey: 'key', listItems: issueFields }, + countField, + { key: 'next_page_token', label: 'Next Page Token' }, + { key: 'is_last', label: 'Last Page', format: 'boolean' }, + ], +}; + +export const bulkGetIssuesOutputSchema: OutputSchema = { + fields: [ + { key: 'issues', label: 'Issues', labelKey: 'key', listItems: issueFields }, + { key: 'issue_errors', label: 'Issue Errors' }, + countField, + ], +}; + +export const createdIssueOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + { key: 'self', label: 'API URL', format: 'url' }, + { key: 'browse_url', label: 'Issue URL', format: 'url' }, + ], +}; + +export const bulkCreateIssuesOutputSchema: OutputSchema = { + fields: [ + { + key: 'issues', + label: 'Created Issues', + labelKey: 'key', + listItems: [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + { key: 'self', label: 'API URL', format: 'url' }, + ], + }, + { key: 'errors', label: 'Errors' }, + { key: 'created_count', label: 'Created', format: 'number' }, + { key: 'failed_count', label: 'Failed', format: 'number' }, + ], +}; + +export const countIssuesOutputSchema: OutputSchema = { + fields: [ + { key: 'count', label: 'Approximate Count', format: 'number' }, + { key: 'jql', label: 'JQL' }, + ], +}; + +export const deleteIssueOutputSchema: OutputSchema = { + fields: [...successFields, { key: 'deleted', label: 'Deleted', format: 'boolean' }], +}; + +export const assignIssueToUserOutputSchema: OutputSchema = { + fields: [...successFields, { key: 'assignee_account_id', label: 'Assignee Account ID' }], +}; + +export const transitionIssueStatusOutputSchema: OutputSchema = { + fields: [ + ...successFields, + { key: 'transition_id', label: 'Transition ID' }, + { key: 'status', label: 'New Status', children: statusFields }, + ], +}; + +export const createIssueLinkOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'link_type', label: 'Link Type' }, + { key: 'outward_issue', label: 'Outward Issue' }, + { key: 'inward_issue', label: 'Inward Issue' }, + ], +}; + +export const watcherChangeOutputSchema: OutputSchema = { + fields: [...successFields, { key: 'account_id', label: 'Account ID' }], +}; + +export const issueWatchersOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Watchers', labelKey: 'displayName', listItems: userFields }, + countField, + { key: 'watch_count', label: 'Watch Count', format: 'number' }, + { key: 'is_watching', label: 'You Are Watching', format: 'boolean' }, + ], +}; + +export const issueVotesOutputSchema: OutputSchema = { + fields: [ + { key: 'votes', label: 'Votes', format: 'number' }, + { key: 'hasVoted', label: 'You Have Voted', format: 'boolean' }, + { key: 'voters', label: 'Voters', labelKey: 'displayName', listItems: userFields }, + ], +}; + +export const remoteIssueLinksOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Remote Links' }, countField], +}; + +export const commentOutputSchema: OutputSchema = { fields: commentFields }; + +export const commentPageOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Comments', labelKey: 'id', listItems: commentFields }, ...pageMetaFields], +}; + +export const deleteCommentOutputSchema: OutputSchema = { + fields: [ + ...successFields, + { key: 'comment_id', label: 'Comment ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const worklogOutputSchema: OutputSchema = { fields: worklogFields }; + +export const worklogPageOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Worklogs', labelKey: 'timeSpent', listItems: worklogFields }, ...pageMetaFields], +}; + +export const deleteWorklogOutputSchema: OutputSchema = { + fields: [ + ...successFields, + { key: 'worklog_id', label: 'Worklog ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const uploadAttachmentOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Attachments', labelKey: 'filename', listItems: attachmentFields }, countField], +}; + +export const downloadAttachmentOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Attachment ID' }, + { key: 'filename', label: 'File Name' }, + { key: 'mime_type', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'author_account_id', label: 'Author Account ID' }, + { key: 'author_display_name', label: 'Author' }, + { key: 'file', label: 'File', format: 'url' }, + ], +}; + +export const deleteAttachmentOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'attachment_id', label: 'Attachment ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const fieldsListOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Fields', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Field ID' }, + { key: 'name', label: 'Name' }, + { key: 'custom', label: 'Custom', format: 'boolean' }, + { key: 'searchable', label: 'Searchable', format: 'boolean' }, + { key: 'clauseNames', label: 'JQL Names' }, + { + key: 'schema', + label: 'Schema', + children: [ + { key: 'type', label: 'Type' }, + { key: 'items', label: 'Item Type' }, + { key: 'custom', label: 'Custom Type' }, + ], + }, + ], + }, + countField, + ], +}; + +export const issueTypeCreateFieldsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Fields', + labelKey: 'name', + listItems: [ + { key: 'fieldId', label: 'Field ID' }, + { key: 'name', label: 'Name' }, + { key: 'required', label: 'Required', format: 'boolean' }, + { key: 'hasDefaultValue', label: 'Has Default', format: 'boolean' }, + { key: 'schema', label: 'Schema', children: [{ key: 'type', label: 'Type' }, { key: 'items', label: 'Item Type' }] }, + { key: 'allowedValues', label: 'Allowed Values' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const issueEditMetadataOutputSchema: OutputSchema = { + fields: [ + { + key: 'fields', + label: 'Editable Fields', + dynamicKey: true, + labelKey: 'name', + children: [ + { key: 'name', label: 'Name' }, + { key: 'required', label: 'Required', format: 'boolean' }, + { key: 'operations', label: 'Operations' }, + { key: 'allowedValues', label: 'Allowed Values' }, + ], + }, + ], +}; + +export const issueTransitionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Transitions', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Transition ID' }, + { key: 'name', label: 'Name' }, + { key: 'to', label: 'Target Status', children: statusFields }, + { key: 'hasScreen', label: 'Has Screen', format: 'boolean' }, + { key: 'isAvailable', label: 'Available', format: 'boolean' }, + { key: 'isConditional', label: 'Conditional', format: 'boolean' }, + { key: 'fields', label: 'Screen Fields' }, + ], + }, + countField, + ], +}; + +export const issueTypesOutputSchema: OutputSchema = { + fields: [ + { key: 'project_id', label: 'Project ID' }, + { key: 'items', label: 'Issue Types', labelKey: 'name', listItems: issueTypeFields }, + countField, + ], +}; + +export const issueLinkTypesOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Link Types', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Link Type ID' }, + { key: 'name', label: 'Name' }, + { key: 'inward', label: 'Inward Description' }, + { key: 'outward', label: 'Outward Description' }, + ], + }, + countField, + ], +}; + +export const resolutionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Resolutions', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Resolution ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'isDefault', label: 'Default', format: 'boolean' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const statusesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Statuses', labelKey: 'name', listItems: statusFields }, countField], +}; + +export const prioritiesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Priorities', labelKey: 'name', listItems: priorityFields }, ...pageMetaFields], +}; + +export const projectPageOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Projects', labelKey: 'name', listItems: projectFields }, ...pageMetaFields], +}; + +export const projectListOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Projects', labelKey: 'name', listItems: projectFields }, countField], +}; + +export const projectOutputSchema: OutputSchema = { + fields: [ + ...projectFields, + { key: 'assigneeType', label: 'Default Assignee' }, + { key: 'issueTypes', label: 'Issue Types', labelKey: 'name', listItems: issueTypeFields }, + { key: 'versions', label: 'Versions', labelKey: 'name', listItems: versionFields }, + { key: 'components', label: 'Components' }, + ], +}; + +export const componentsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Components' }, ...pageMetaFields], +}; + +export const versionsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Versions', labelKey: 'name', listItems: versionFields }, ...pageMetaFields], +}; + +export const boardsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Boards', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Board ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'isPrivate', label: 'Private', format: 'boolean' }, + { + key: 'location', + label: 'Location', + children: [ + { key: 'projectId', label: 'Project ID' }, + { key: 'projectKey', label: 'Project Key' }, + { key: 'projectName', label: 'Project Name' }, + ], + }, + ], + }, + ...pageMetaFields, + ], +}; + +export const boardOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Board ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + ], +}; + +export const sprintOutputSchema: OutputSchema = { fields: sprintFields }; + +export const sprintsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Sprints', labelKey: 'name', listItems: sprintFields }, ...pageMetaFields], +}; + +export const moveIssuesToSprintOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'sprint_id', label: 'Sprint ID' }, + { key: 'issues', label: 'Moved Issues' }, + countField, + ], +}; + +export const currentUserOutputSchema: OutputSchema = { + fields: [ + ...userFields, + { key: 'locale', label: 'Locale' }, + { + key: 'groups', + label: 'Groups', + children: [ + { key: 'size', label: 'Group Count', format: 'number' }, + { + key: 'items', + label: 'Groups', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'groupId', label: 'Group ID' }, + ], + }, + ], + }, + ], +}; + +export const usersOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Users', labelKey: 'displayName', listItems: userFields }, countField], +}; + +export const userGroupsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Groups', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'groupId', label: 'Group ID' }, + ], + }, + countField, + ], +}; + +export const myPermissionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Permissions', + labelKey: 'key', + listItems: [ + { key: 'key', label: 'Permission Key' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'havePermission', label: 'Granted', format: 'boolean' }, + { key: 'description', label: 'Description' }, + ], + }, + countField, + ], +}; + +export const permittedProjectsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Projects', + labelKey: 'key', + listItems: [ + { key: 'id', label: 'Project ID' }, + { key: 'key', label: 'Project Key' }, + ], + }, + countField, + ], +}; + +export const filterOutputSchema: OutputSchema = { + fields: [ + ...filterFields, + { key: 'searchUrl', label: 'Search API URL', format: 'url' }, + { key: 'favouritedCount', label: 'Favourited By', format: 'number' }, + ], +}; + +export const filterPageOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Filters', labelKey: 'name', listItems: filterFields }, ...pageMetaFields], +}; + +export const filterListOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Filters', labelKey: 'name', listItems: filterFields }, countField], +}; + +export const dashboardsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Dashboards', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Dashboard ID' }, + { key: 'name', label: 'Name' }, + { key: 'view', label: 'View Path' }, + { key: 'isFavourite', label: 'Favourite', format: 'boolean' }, + ], + }, + ...pageMetaFields, + ], +}; + +export const jqlReferenceDataOutputSchema: OutputSchema = { + fields: [ + { + key: 'fields', + label: 'Fields', + labelKey: 'displayName', + listItems: [ + { key: 'value', label: 'JQL Name' }, + { key: 'displayName', label: 'Display Name' }, + { key: 'cfid', label: 'Custom Field Reference' }, + { key: 'operators', label: 'Operators' }, + { key: 'types', label: 'Types' }, + ], + }, + { key: 'field_count', label: 'Field Count', format: 'number' }, + { + key: 'functions', + label: 'Functions', + labelKey: 'value', + listItems: [ + { key: 'value', label: 'Function' }, + { key: 'displayName', label: 'Display Name' }, + ], + }, + { key: 'reserved_words', label: 'Reserved Words' }, + ], +}; + +export const jqlSuggestionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Suggestions', + labelKey: 'value', + listItems: [ + { key: 'value', label: 'Value' }, + { key: 'displayName', label: 'Display Name', format: 'html' }, + ], + }, + countField, + ], +}; + +export const parseJqlOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Parsed Queries', + labelKey: 'query', + listItems: [ + { key: 'query', label: 'Query' }, + { key: 'errors', label: 'Errors' }, + { key: 'warnings', label: 'Warnings' }, + { key: 'structure', label: 'Structure' }, + ], + }, + countField, + ], +}; + +export const legacyAttachmentListOutputSchema: OutputSchema = { + itemLabel: '{filename}', + fields: [{ key: 'attachments', label: 'Attachments', value: '', listItems: attachmentFields }], +}; + +export const legacyGetAttachmentOutputSchema: OutputSchema = { + fields: [...attachmentFields, { key: 'file', label: 'File', format: 'url' }], +}; + +export const legacyCommentPageOutputSchema: OutputSchema = { + fields: [ + { key: 'comments', label: 'Comments', labelKey: 'id', listItems: commentFields }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'startAt', label: 'Start At', format: 'number' }, + { key: 'maxResults', label: 'Max Results', format: 'number' }, + ], +}; + +export const legacyFindUserOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'data', label: 'Users', labelKey: 'displayName', listItems: userFields }, + ], +}; + +export const legacyLinkIssuesOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const legacyAddWatcherOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'error', label: 'Error' }, + ], +}; + +export const legacyTransitionIssueOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'issue', label: 'Issue', children: issueFields }, + ], +}; + +export const markdownToAdfOutputSchema: OutputSchema = { + fields: [ + { key: 'type', label: 'Document Type' }, + { key: 'version', label: 'Version', format: 'number' }, + { key: 'content', label: 'Content' }, + ], +}; + +export const newCommentTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'issue', label: 'Issue', children: issueSummaryFields }, + { key: 'comment', label: 'Comment', children: commentFields }, + ], +}; + +export const newAttachmentTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'issue', label: 'Issue', children: issueSummaryFields }, + { key: 'attachment', label: 'Attachment', children: attachmentFields }, + { key: 'addedBy', label: 'Added By', children: userFields }, + { key: 'addedAt', label: 'Added At', format: 'datetime' }, + ], +}; + +export const issueAssignedTriggerOutputSchema: OutputSchema = { + fields: [ + { + key: 'issue', + label: 'Issue', + children: [ + { key: 'id', label: 'Issue ID' }, + { key: 'key', label: 'Issue Key' }, + { key: 'fields', label: 'Fields', children: issueFieldsFields }, + ], + }, + { + key: 'change', + label: 'Assignment', + children: [ + { + key: 'from', + label: 'Previous Assignee', + children: [ + { key: 'accountId', label: 'Account ID' }, + { key: 'displayName', label: 'Display Name' }, + ], + }, + { + key: 'to', + label: 'New Assignee', + children: [ + { key: 'accountId', label: 'Account ID' }, + { key: 'displayName', label: 'Display Name' }, + ], + }, + { key: 'by', label: 'Changed By', children: userFields }, + { key: 'at', label: 'Changed At', format: 'datetime' }, + ], + }, + ], +}; + +export const issueTypeTriggerOutputSchema: OutputSchema = { fields: issueTypeFields }; + +export const projectTriggerOutputSchema: OutputSchema = { fields: projectFields }; + +export const priorityTriggerOutputSchema: OutputSchema = { fields: priorityFields }; diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/issue-assigned.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/issue-assigned.ts index f3b11d9ae05a..664e0e01b274 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/issue-assigned.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/issue-assigned.ts @@ -9,6 +9,7 @@ import { JiraPollingItem, JiraPollingProps, createJiraPolling } from '../common/ import { getUsersDropdown } from '../common/props'; import { ChangelogHistory } from '../common/types'; +import { issueAssignedTriggerOutputSchema } from '../output-schemas'; type IssueWithChangelog = { id: string; key: string; @@ -92,6 +93,7 @@ export const issueAssigned = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: issueAssignedTriggerOutputSchema, props: { assignedToMe: Property.Checkbox({ displayName: 'Only when assigned to me', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-attachment.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-attachment.ts index 01146e75da84..28fbb4bb363e 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-attachment.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-attachment.ts @@ -8,6 +8,7 @@ import { jiraCloudAuth } from '../../auth'; import { JiraPollingItem, createJiraPolling } from '../common/polling'; import { ChangelogHistory } from '../common/types'; +import { newAttachmentTriggerOutputSchema } from '../output-schemas'; type JiraAttachment = { id: string; self: string; @@ -80,6 +81,7 @@ export const newAttachment = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: newAttachmentTriggerOutputSchema, props: { jql: Property.LongText({ displayName: 'Only watch these issues (optional)', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-comment.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-comment.ts index e24f9fa055e5..b540d68da90a 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-comment.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-comment.ts @@ -6,6 +6,7 @@ import { import { jiraCloudAuth } from '../../auth'; import { JiraPollingItem, createJiraPolling } from '../common/polling'; +import { newCommentTriggerOutputSchema } from '../output-schemas'; type JiraComment = { id: string; self: string; @@ -58,6 +59,7 @@ export const newComment = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: newCommentTriggerOutputSchema, props: { jql: Property.LongText({ displayName: 'Only watch these issues (optional)', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue-type.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue-type.ts index d0d5a5303d40..80d8d26e903d 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue-type.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue-type.ts @@ -3,6 +3,7 @@ import { jiraCloudAuth } from '../../auth'; import { getIssueTypes } from '../common'; import { getProjectIdDropdown } from '../common/props'; +import { issueTypeTriggerOutputSchema } from '../output-schemas'; export const newIssueType = createTrigger({ name: 'new_issue_type', displayName: 'New Issue Type', @@ -13,6 +14,7 @@ export const newIssueType = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: issueTypeTriggerOutputSchema, props: { projectId: getProjectIdDropdown({ displayName: 'Project', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue.ts index 2d4c37dcaa39..ba9c864bb3ce 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-issue.ts @@ -6,6 +6,7 @@ import { import { jiraCloudAuth } from '../../auth'; import { createJiraPolling } from '../common/polling'; +import { issueOutputSchema } from '../output-schemas'; const polling = createJiraPolling({ timeField: 'created' }); export const newIssue = createTrigger({ @@ -18,6 +19,7 @@ export const newIssue = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: issueOutputSchema, props: { jql: Property.LongText({ displayName: 'JQL', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-priority.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-priority.ts index f50ff15b6b96..c02ff1e1813b 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-priority.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-priority.ts @@ -2,6 +2,7 @@ import { TriggerStrategy, createTrigger, isNil } from '@activepieces/pieces-fram import { jiraCloudAuth } from '../../auth'; import { getPriorities } from '../common'; +import { priorityTriggerOutputSchema } from '../output-schemas'; export const newPriority = createTrigger({ name: 'new_priority', displayName: 'New Priority', @@ -12,6 +13,7 @@ export const newPriority = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: priorityTriggerOutputSchema, props: {}, sampleData: { self: 'https://instance.atlassian.net/rest/api/3/priority/1', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/new-project.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/new-project.ts index 508e66990622..72d2131697f4 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/new-project.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/new-project.ts @@ -2,6 +2,7 @@ import { TriggerStrategy, createTrigger, isNil } from '@activepieces/pieces-fram import { jiraCloudAuth } from '../../auth'; import { getProjects } from '../common'; +import { projectTriggerOutputSchema } from '../output-schemas'; export const newProject = createTrigger({ name: 'new_project', displayName: 'New Project', @@ -12,6 +13,7 @@ export const newProject = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: projectTriggerOutputSchema, props: {}, sampleData: { id: '10000', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue-status.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue-status.ts index 953283d5a102..f640af4fa08a 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue-status.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue-status.ts @@ -6,6 +6,7 @@ import { import { jiraCloudAuth } from '../../auth'; import { createJiraPolling } from '../common/polling'; +import { issueOutputSchema } from '../output-schemas'; const polling = createJiraPolling({ epochField: 'statuscategorychangedate' }); export const updatedIssueStatus = createTrigger({ @@ -18,6 +19,7 @@ export const updatedIssueStatus = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: issueOutputSchema, props: { jql: Property.LongText({ displayName: 'JQL', diff --git a/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue.ts b/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue.ts index 701297698a45..1df8704cf95c 100644 --- a/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue.ts +++ b/packages/pieces/community/jira-cloud/src/lib/triggers/updated-issue.ts @@ -6,6 +6,7 @@ import { import { jiraCloudAuth } from '../../auth'; import { createJiraPolling } from '../common/polling'; +import { issueOutputSchema } from '../output-schemas'; const polling = createJiraPolling(); export const updatedIssue = createTrigger({ @@ -18,6 +19,7 @@ export const updatedIssue = createTrigger({ }, auth: jiraCloudAuth, type: TriggerStrategy.POLLING, + outputSchema: issueOutputSchema, props: { jql: Property.LongText({ displayName: 'JQL', diff --git a/packages/pieces/community/mistral-ai/package.json b/packages/pieces/community/mistral-ai/package.json index 7a9729bbae48..dece296a798d 100644 --- a/packages/pieces/community/mistral-ai/package.json +++ b/packages/pieces/community/mistral-ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-mistral-ai", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/mistral-ai/src/index.ts b/packages/pieces/community/mistral-ai/src/index.ts index 3c59769bab77..0f62d924531f 100644 --- a/packages/pieces/community/mistral-ai/src/index.ts +++ b/packages/pieces/community/mistral-ai/src/index.ts @@ -5,6 +5,48 @@ import { createEmbeddings } from "./lib/actions/create-embeddings"; import { uploadFile } from "./lib/actions/upload-file"; import { runOcr } from "./lib/actions/run-ocr"; import { listModels } from "./lib/actions/list-models"; +import { generateChatCompletion } from "./lib/actions/generate-chat-completion"; +import { completeCodeFim } from "./lib/actions/complete-code-fim"; +import { transcribeAudio } from "./lib/actions/transcribe-audio"; +import { generateSpeech } from "./lib/actions/generate-speech"; +import { moderateText } from "./lib/actions/moderate-text"; +import { moderateChat } from "./lib/actions/moderate-chat"; +import { getModel } from "./lib/actions/get-model"; +import { listFiles } from "./lib/actions/list-files"; +import { getFile } from "./lib/actions/get-file"; +import { deleteFile } from "./lib/actions/delete-file"; +import { getFileSignedUrl } from "./lib/actions/get-file-signed-url"; +import { downloadFile } from "./lib/actions/download-file"; +import { createAgent } from "./lib/actions/create-agent"; +import { listAgents } from "./lib/actions/list-agents"; +import { getAgent } from "./lib/actions/get-agent"; +import { updateAgent } from "./lib/actions/update-agent"; +import { deleteAgent } from "./lib/actions/delete-agent"; +import { startConversation } from "./lib/actions/start-conversation"; +import { appendToConversation } from "./lib/actions/append-to-conversation"; +import { restartConversation } from "./lib/actions/restart-conversation"; +import { listConversations } from "./lib/actions/list-conversations"; +import { getConversation } from "./lib/actions/get-conversation"; +import { getConversationHistory } from "./lib/actions/get-conversation-history"; +import { getConversationMessages } from "./lib/actions/get-conversation-messages"; +import { deleteConversation } from "./lib/actions/delete-conversation"; +import { createLibrary } from "./lib/actions/create-library"; +import { listLibraries } from "./lib/actions/list-libraries"; +import { getLibrary } from "./lib/actions/get-library"; +import { updateLibrary } from "./lib/actions/update-library"; +import { deleteLibrary } from "./lib/actions/delete-library"; +import { uploadLibraryDocument } from "./lib/actions/upload-library-document"; +import { listLibraryDocuments } from "./lib/actions/list-library-documents"; +import { getLibraryDocument } from "./lib/actions/get-library-document"; +import { updateLibraryDocument } from "./lib/actions/update-library-document"; +import { deleteLibraryDocument } from "./lib/actions/delete-library-document"; +import { getDocumentStatus } from "./lib/actions/get-document-status"; +import { getDocumentTextContent } from "./lib/actions/get-document-text-content"; +import { reprocessDocument } from "./lib/actions/reprocess-document"; +import { listBatchJobs } from "./lib/actions/list-batch-jobs"; +import { createBatchJob } from "./lib/actions/create-batch-job"; +import { getBatchJob } from "./lib/actions/get-batch-job"; +import { cancelBatchJob } from "./lib/actions/cancel-batch-job"; import { mistralAuth } from "./lib/common/auth"; import { mistralRequest } from "./lib/common/request"; import { createCustomApiCallAction } from "@activepieces/pieces-common"; @@ -13,7 +55,7 @@ export const mistralAi = createPiece({ displayName: "Mistral AI", description: "Mistral AI provides state-of-the-art open-weight and hosted language models for text generation, embeddings, and reasoning tasks.", auth: mistralAuth, - minimumSupportedRelease: "0.36.1", + minimumSupportedRelease: "0.87.0", logoUrl: "https://cdn.activepieces.com/pieces/mistral-ai.png", authors: ["sparkybug"], categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], @@ -23,6 +65,48 @@ export const mistralAi = createPiece({ uploadFile, runOcr, listModels, + generateChatCompletion, + completeCodeFim, + transcribeAudio, + generateSpeech, + moderateText, + moderateChat, + getModel, + listFiles, + getFile, + deleteFile, + getFileSignedUrl, + downloadFile, + createAgent, + listAgents, + getAgent, + updateAgent, + deleteAgent, + startConversation, + appendToConversation, + restartConversation, + listConversations, + getConversation, + getConversationHistory, + getConversationMessages, + deleteConversation, + createLibrary, + listLibraries, + getLibrary, + updateLibrary, + deleteLibrary, + uploadLibraryDocument, + listLibraryDocuments, + getLibraryDocument, + updateLibraryDocument, + deleteLibraryDocument, + getDocumentStatus, + getDocumentTextContent, + reprocessDocument, + listBatchJobs, + createBatchJob, + getBatchJob, + cancelBatchJob, createCustomApiCallAction({ auth: mistralAuth, baseUrl: (auth) => (auth ? mistralRequest.getConfig(auth).baseUrl : 'https://api.mistral.ai/v1'), diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/append-to-conversation.ts b/packages/pieces/community/mistral-ai/src/lib/actions/append-to-conversation.ts new file mode 100644 index 000000000000..44894ac3e940 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/append-to-conversation.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils, ConversationResponse } from '../common/conversations'; +import { conversationReplyOutputSchema } from '../output-schemas'; + +export const appendToConversation = createAction({ + auth: mistralAuth, + name: 'append_to_conversation', + classification: 'WRITE', + displayName: 'Append to Conversation', + description: 'Send a follow-up message in an existing conversation (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Sends a follow-up user message to an existing stored conversation (Beta) and returns the new reply and output entries; Mistral keeps the earlier turns, so only send the new message. Needs a conversation id from Start Conversation or List Conversations. Use Restart Conversation to branch from an earlier entry instead. Not idempotent: each call adds a turn and bills a new reply.', + idempotent: false, + }, + outputSchema: conversationReplyOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + message: Property.LongText({ + displayName: 'Message', + description: 'The next user message.', + required: true, + }), + }, + async run(context) { + const { conversation_id, message } = context.propsValue; + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: `/conversations/${encodeURIComponent(conversation_id)}`, + body: { inputs: message, stream: false, store: true }, + timeout: 300000, + }); + return conversationUtils.formatResponse(response); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/cancel-batch-job.ts b/packages/pieces/community/mistral-ai/src/lib/actions/cancel-batch-job.ts new file mode 100644 index 000000000000..1b80526ad3b2 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/cancel-batch-job.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { batchUtils, MistralBatchJob } from '../common/batch'; +import { batchJobOutputSchema } from '../output-schemas'; + +export const cancelBatchJob = createAction({ + auth: mistralAuth, + name: 'cancel_batch_job', + classification: 'DESTRUCTIVE', + displayName: 'Cancel Batch Job', + description: 'Stop a queued or running batch job.', + audience: 'ai', + aiMetadata: { + description: + 'Requests cancellation of a queued or running batch job and returns its updated status (usually CANCELLATION_REQUESTED, then CANCELLED); requests already completed stay billed and their results are kept. Get the job id from List Batch Jobs. Cancelling an already-cancelled job leaves it cancelled, so a retry is harmless.', + idempotent: true, + }, + outputSchema: batchJobOutputSchema, + props: { + job_id: batchUtils.jobIdProp(), + }, + async run(context) { + const job = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: `/batch/jobs/${encodeURIComponent(context.propsValue.job_id)}/cancel`, + }); + return batchUtils.formatJob(job); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/complete-code-fim.ts b/packages/pieces/community/mistral-ai/src/lib/actions/complete-code-fim.ts new file mode 100644 index 000000000000..bc4e53aa7034 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/complete-code-fim.ts @@ -0,0 +1,80 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { completeCodeFimOutputSchema } from '../output-schemas'; + +export const completeCodeFim = createAction({ + auth: mistralAuth, + name: 'complete_code_fim', + classification: 'READ', + displayName: 'Complete Code (Fill-in-the-Middle)', + description: 'Generate the code that belongs between a prefix and an optional suffix with a Codestral model.', + audience: 'ai', + aiMetadata: { + description: + 'Fills in code between a prefix (prompt) and an optional suffix using a Codestral fill-in-the-middle model, returning only the generated middle text. Pick this for code insertion or completion inside an existing file; use Generate Chat Completion for explaining or writing code from an instruction. Only Codestral models support this endpoint. Not idempotent: each call bills a new completion.', + idempotent: false, + }, + outputSchema: completeCodeFimOutputSchema, + props: { + model: Property.ShortText({ + displayName: 'Model', + description: 'A Codestral model id. Fill-in-the-middle only works with Codestral models.', + required: true, + defaultValue: 'codestral-latest', + }), + prompt: Property.LongText({ + displayName: 'Code Before (Prompt)', + description: 'The code that comes before the part to generate.', + required: true, + }), + suffix: Property.LongText({ + displayName: 'Code After (Suffix)', + description: 'Optional code that comes after the part to generate.', + required: false, + }), + max_tokens: Property.Number({ displayName: 'Max Tokens', required: false }), + temperature: Property.Number({ displayName: 'Temperature', required: false }), + stop: Property.Array({ + displayName: 'Stop Sequences', + description: 'Stop generating when any of these strings is produced.', + required: false, + }), + }, + async run(context) { + const { model, prompt, suffix, max_tokens, temperature, stop } = context.propsValue; + const stopSequences = mistralApi.toStringArray(stop); + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/fim/completions', + body: mistralApi.compact({ + model, + prompt, + suffix, + max_tokens, + temperature, + stop: stopSequences.length > 0 ? stopSequences : undefined, + }), + timeout: 120000, + }); + const choice = response.choices[0]; + return { + id: response.id, + model: response.model, + completion: mistralApi.contentToText(choice?.message?.content), + finish_reason: choice?.finish_reason ?? null, + prompt_tokens: response.usage?.prompt_tokens ?? null, + completion_tokens: response.usage?.completion_tokens ?? null, + total_tokens: response.usage?.total_tokens ?? null, + }; + }, +}); + +type FimResponse = { + id: string; + model: string; + choices: { finish_reason: string; message?: { content: unknown } }[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-agent.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-agent.ts new file mode 100644 index 000000000000..73412c8cd380 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-agent.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { agentUtils, MistralAgent } from '../common/agents'; +import { agentOutputSchema } from '../output-schemas'; + +export const createAgent = createAction({ + auth: mistralAuth, + name: 'create_agent', + classification: 'WRITE', + displayName: 'Create Agent', + description: 'Create a Mistral agent with a model, instructions and tools (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Creates a reusable Mistral agent (Beta) from a model, a name, optional instructions, built-in tools (web search, code interpreter, image generation) and document libraries, and returns its agent id. Run the agent with Start Conversation using that id; change it later with Update Agent. Not idempotent: each call creates another agent, even with the same name.', + idempotent: false, + }, + outputSchema: agentOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name', required: true }), + model: Property.ShortText({ + displayName: 'Model', + description: 'Chat model id the agent runs on, e.g. mistral-medium-latest.', + required: true, + defaultValue: 'mistral-medium-latest', + }), + instructions: Property.LongText({ + displayName: 'Instructions', + description: 'The system prompt the agent follows in every conversation.', + required: false, + }), + description: Property.ShortText({ displayName: 'Description', required: false }), + ...agentUtils.toolsProps(), + temperature: Property.Number({ displayName: 'Temperature', required: false }), + }, + async run(context) { + const { name, model, instructions, description, built_in_tools, library_ids, temperature } = context.propsValue; + const agent = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/agents', + body: mistralApi.compact({ + name, + model, + instructions, + description, + tools: agentUtils.buildTools({ + builtInTools: mistralApi.toStringArray(built_in_tools), + libraryIds: mistralApi.toStringArray(library_ids), + }), + completion_args: temperature === undefined || temperature === null ? undefined : { temperature }, + }), + }); + return agentUtils.formatAgent(agent); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-batch-job.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-batch-job.ts new file mode 100644 index 000000000000..84d20028c406 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-batch-job.ts @@ -0,0 +1,76 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { BATCH_ENDPOINT_OPTIONS, batchUtils, MistralBatchJob } from '../common/batch'; +import { batchJobOutputSchema } from '../output-schemas'; + +export const createBatchJob = createAction({ + auth: mistralAuth, + name: 'create_batch_job', + classification: 'WRITE', + displayName: 'Create Batch Job', + description: 'Start a batch inference job over many requests.', + audience: 'ai', + aiMetadata: { + description: + 'Starts an asynchronous batch inference job that runs many requests against one endpoint and model at reduced cost, and returns the job id and initial status (not the results). Give either uploaded .jsonl input file ids (Upload File with purpose batch) or inline requests. Poll Get Batch Job until the status is SUCCESS, then fetch the output file with Download File. Not idempotent: each call starts and bills a new job.', + idempotent: false, + }, + outputSchema: batchJobOutputSchema, + props: { + endpoint: Property.StaticDropdown({ + displayName: 'Endpoint', + required: true, + defaultValue: '/v1/chat/completions', + options: { options: BATCH_ENDPOINT_OPTIONS }, + }), + model: Property.ShortText({ + displayName: 'Model', + description: 'Model id to run every request with, e.g. mistral-small-latest.', + required: false, + }), + input_files: Property.Array({ + displayName: 'Input File IDs', + description: 'UUIDs of .jsonl files uploaded with purpose batch. Each line needs a custom_id and a body.', + required: false, + }), + requests: Property.Json({ + displayName: 'Inline Requests', + description: 'Instead of files, a JSON array of requests, each {"custom_id": "...", "body": {...}}.', + required: false, + }), + metadata: Property.Json({ + displayName: 'Metadata', + description: 'Optional JSON object stored with the job.', + required: false, + }), + timeout_hours: Property.Number({ + displayName: 'Timeout (hours)', + required: false, + defaultValue: 24, + }), + }, + async run(context) { + const { endpoint, model, input_files, requests, metadata, timeout_hours } = context.propsValue; + const inputFiles = mistralApi.toStringArray(input_files); + const inlineRequests = mistralApi.parseJsonInput({ value: requests, fieldName: 'Inline Requests' }); + if (inputFiles.length === 0 && (!Array.isArray(inlineRequests) || inlineRequests.length === 0)) { + throw new Error('Provide Input File IDs or a non-empty Inline Requests array.'); + } + const job = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/batch/jobs', + body: mistralApi.compact({ + endpoint, + model, + input_files: inputFiles.length > 0 ? inputFiles : undefined, + requests: Array.isArray(inlineRequests) && inlineRequests.length > 0 ? inlineRequests : undefined, + metadata: mistralApi.parseJsonInput({ value: metadata, fieldName: 'Metadata' }), + timeout_hours, + }), + }); + return batchUtils.formatJob(job); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts index 09708ff88f1c..2d55d57dd406 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts @@ -5,7 +5,7 @@ import { modelDropdown, parseMistralError } from '../common/props'; import { mistralRequest } from '../common/request'; export const createChatCompletion = createAction({ - audience: 'both', + audience: 'human', auth: mistralAuth, name: 'create_chat_completion', classification: 'READ', diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts index bd32d3216585..68327ba622e0 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts @@ -3,6 +3,7 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import { parseMistralError } from '../common/props'; import { mistralRequest } from '../common/request'; +import { createEmbeddingsOutputSchema } from '../output-schemas'; export const createEmbeddings = createAction({ audience: 'both', @@ -12,6 +13,7 @@ export const createEmbeddings = createAction({ displayName: 'Create Embeddings', description: 'Creates new embedding in Mistral AI.', aiMetadata: { description: 'Converts an array of text strings into numeric embedding vectors using Mistral\'s fixed mistral-embed model; the model is not selectable here, unlike the sibling Ask Mistral action, and a whole batch of strings is embedded in one call. Use it for semantic search, similarity, or clustering pipelines, and pick Ask Mistral when you need generated prose rather than numbers. Requires a non-empty array of input strings; idempotent: nothing is created server-side and the same input yields the same vectors.', idempotent: true }, + outputSchema: createEmbeddingsOutputSchema, props: { input: Property.Array({ displayName: 'Input', diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-library.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-library.ts new file mode 100644 index 000000000000..277c458f234a --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-library.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralLibrary } from '../common/libraries'; +import { libraryOutputSchema } from '../output-schemas'; + +export const createLibrary = createAction({ + auth: mistralAuth, + name: 'create_library', + classification: 'WRITE', + displayName: 'Create Library', + description: 'Create a document library for agents to search (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Creates an empty Mistral document library (Beta) and returns its UUID; add files with Upload Library Document and attach the library to an agent through Create Agent or Update Agent. Owner type User makes it private, Workspace shares it with the workspace. Not idempotent: each call creates another library, even with the same name.', + idempotent: false, + }, + outputSchema: libraryOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name', required: true }), + description: Property.LongText({ displayName: 'Description', required: false }), + owner_type: Property.StaticDropdown({ + displayName: 'Owner', + description: 'User keeps the library private; Workspace shares it with your workspace.', + required: false, + options: { + options: [ + { label: 'User (private)', value: 'User' }, + { label: 'Workspace', value: 'Workspace' }, + ], + }, + }), + chunk_size: Property.Number({ + displayName: 'Chunk Size', + description: 'Characters per chunk when splitting documents, between 256 and 32768. Leave empty for the default.', + required: false, + }), + }, + async run(context) { + const { name, description, owner_type, chunk_size } = context.propsValue; + const library = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/libraries', + body: mistralApi.compact({ name, description, owner_type, chunk_size }), + }); + return libraryUtils.formatLibrary(library); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/delete-agent.ts b/packages/pieces/community/mistral-ai/src/lib/actions/delete-agent.ts new file mode 100644 index 000000000000..72495ea5feaa --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/delete-agent.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { deleteAgentOutputSchema } from '../output-schemas'; + +export const deleteAgent = createAction({ + auth: mistralAuth, + name: 'delete_agent', + classification: 'DESTRUCTIVE', + displayName: 'Delete Agent', + description: 'Permanently delete a Mistral agent (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a Mistral agent (Beta) and all its versions by agent id; conversations can no longer be started with it. Confirm the id with Get Agent or List Agents first. Not idempotent: a repeat call fails because the agent is gone.', + idempotent: false, + }, + outputSchema: deleteAgentOutputSchema, + props: { + agent_id: Property.ShortText({ + displayName: 'Agent ID', + description: 'The agent id (usually starts with ag_), from List Agents.', + required: true, + }), + }, + async run(context) { + const { agent_id } = context.propsValue; + await mistralApi.call({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/agents/${encodeURIComponent(agent_id)}`, + }); + return { agent_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/delete-conversation.ts b/packages/pieces/community/mistral-ai/src/lib/actions/delete-conversation.ts new file mode 100644 index 000000000000..3f17109b9aaa --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/delete-conversation.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils } from '../common/conversations'; +import { deleteConversationOutputSchema } from '../output-schemas'; + +export const deleteConversation = createAction({ + auth: mistralAuth, + name: 'delete_conversation', + classification: 'DESTRUCTIVE', + displayName: 'Delete Conversation', + description: 'Permanently delete a stored conversation (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a stored conversation (Beta) and its history by conversation id; it can no longer be appended to or restarted. Confirm the id with List Conversations first. Not idempotent: a repeat call fails because the conversation is gone.', + idempotent: false, + }, + outputSchema: deleteConversationOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + }, + async run(context) { + const { conversation_id } = context.propsValue; + await mistralApi.call({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/conversations/${encodeURIComponent(conversation_id)}`, + }); + return { conversation_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/delete-file.ts b/packages/pieces/community/mistral-ai/src/lib/actions/delete-file.ts new file mode 100644 index 000000000000..9e171b76bbc9 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/delete-file.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { deleteFileOutputSchema } from '../output-schemas'; + +export const deleteFile = createAction({ + auth: mistralAuth, + name: 'delete_file', + classification: 'DESTRUCTIVE', + displayName: 'Delete File', + description: 'Permanently delete an uploaded file.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one uploaded file and its metadata by UUID; this cannot be undone and breaks any batch job or OCR call that still references it. Confirm the id with List Files or Get File first. Not idempotent: a repeat call fails because the file is gone.', + idempotent: false, + }, + outputSchema: deleteFileOutputSchema, + props: { + file_id: Property.ShortText({ + displayName: 'File ID', + description: 'The file UUID, from List Files.', + required: true, + }), + }, + async run(context) { + const response = await mistralApi.call<{ id: string; deleted: boolean }>({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/files/${encodeURIComponent(context.propsValue.file_id)}`, + }); + return { id: response.id, deleted: response.deleted }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/delete-library-document.ts b/packages/pieces/community/mistral-ai/src/lib/actions/delete-library-document.ts new file mode 100644 index 000000000000..17a946843828 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/delete-library-document.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils } from '../common/libraries'; +import { deleteLibraryDocumentOutputSchema } from '../output-schemas'; + +export const deleteLibraryDocument = createAction({ + auth: mistralAuth, + name: 'delete_library_document', + classification: 'DESTRUCTIVE', + displayName: 'Delete Library Document', + description: 'Permanently remove a document from a library (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Permanently removes one document from a library (Beta) by library and document UUID; agents using the library stop finding it. Use Delete Library to remove the whole library. Not idempotent: a repeat call fails because the document is gone.', + idempotent: false, + }, + outputSchema: deleteLibraryDocumentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + }, + async run(context) { + const { library_id, document_id } = context.propsValue; + await mistralApi.call({ + auth: context.auth, + method: HttpMethod.DELETE, + path: libraryUtils.documentPath({ libraryId: library_id, documentId: document_id }), + }); + return { library_id, document_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/delete-library.ts b/packages/pieces/community/mistral-ai/src/lib/actions/delete-library.ts new file mode 100644 index 000000000000..2bc407b24667 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/delete-library.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralLibrary } from '../common/libraries'; +import { deleteLibraryOutputSchema } from '../output-schemas'; + +export const deleteLibrary = createAction({ + auth: mistralAuth, + name: 'delete_library', + classification: 'DESTRUCTIVE', + displayName: 'Delete Library', + description: 'Permanently delete a document library and all its documents (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a document library (Beta) and every document in it by UUID; agents that use it lose access. Use Delete Library Document to remove a single document instead. Not idempotent: a repeat call fails because the library is gone.', + idempotent: false, + }, + outputSchema: deleteLibraryOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + }, + async run(context) { + const library = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/libraries/${encodeURIComponent(context.propsValue.library_id)}`, + }); + return { ...libraryUtils.formatLibrary(library), deleted: true }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/download-file.ts b/packages/pieces/community/mistral-ai/src/lib/actions/download-file.ts new file mode 100644 index 000000000000..83976e1bb5b3 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/download-file.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { MistralFile } from '../common/files'; +import { downloadFileOutputSchema } from '../output-schemas'; + +export const downloadFile = createAction({ + auth: mistralAuth, + name: 'download_file', + classification: 'READ', + displayName: 'Download File', + description: 'Download the content of an uploaded file.', + audience: 'ai', + aiMetadata: { + description: + 'Downloads the content of one Mistral file by UUID and returns it as a file the flow can pass on, together with its name and size. Use it to fetch batch results or error files (their ids come from Get Batch Job) or anything else in List Files. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: downloadFileOutputSchema, + props: { + file_id: Property.ShortText({ + displayName: 'File ID', + description: 'The file UUID, from List Files or a batch job’s output file.', + required: true, + }), + }, + async run(context) { + const fileId = encodeURIComponent(context.propsValue.file_id); + const metadata = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/files/${fileId}`, + }); + const content = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/files/${fileId}/content`, + responseType: 'arraybuffer', + timeout: 300000, + }); + const data = Buffer.from(content); + const file = await context.files.write({ fileName: metadata.filename, data }); + return { + file, + file_id: metadata.id, + file_name: metadata.filename, + size_bytes: data.byteLength, + mimetype: metadata.mimetype ?? null, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/generate-chat-completion.ts b/packages/pieces/community/mistral-ai/src/lib/actions/generate-chat-completion.ts new file mode 100644 index 000000000000..c3d045371810 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/generate-chat-completion.ts @@ -0,0 +1,121 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { generateChatCompletionOutputSchema } from '../output-schemas'; + +export const generateChatCompletion = createAction({ + auth: mistralAuth, + name: 'generate_chat_completion', + classification: 'READ', + displayName: 'Generate Chat Completion', + description: 'Generate a reply from a Mistral chat model given a system prompt and a list of messages.', + audience: 'ai', + aiMetadata: { + description: + 'Generates one assistant reply from a Mistral chat model for a multi-turn message list, with an optional system prompt and JSON output mode, and returns the reply text plus finish reason and token usage. Pick this for stateless text generation where you supply the whole history; use Start Conversation instead when Mistral should store the thread or run a Mistral agent. Model ids come from List Models. Not idempotent: each call bills a new completion and the wording varies unless a random seed is set.', + idempotent: false, + }, + outputSchema: generateChatCompletionOutputSchema, + props: { + model: Property.ShortText({ + displayName: 'Model', + description: 'Chat model id, e.g. mistral-large-latest or mistral-small-latest. Use List Models to see what your key can access.', + required: true, + defaultValue: 'mistral-small-latest', + }), + system_prompt: Property.LongText({ + displayName: 'System Prompt', + description: 'Optional instructions that set the model behaviour for the whole reply.', + required: false, + }), + messages: Property.Array({ + displayName: 'Messages', + description: 'The conversation so far, oldest first. The last message is usually from the user.', + required: true, + properties: { + role: Property.StaticDropdown({ + displayName: 'Role', + required: true, + defaultValue: 'user', + options: { + options: [ + { label: 'User', value: 'user' }, + { label: 'Assistant', value: 'assistant' }, + ], + }, + }), + content: Property.LongText({ displayName: 'Content', required: true }), + }, + }), + response_format: Property.StaticDropdown({ + displayName: 'Response Format', + description: 'Choose JSON to force the model to return a valid JSON object. Also ask for JSON in the prompt.', + required: false, + defaultValue: 'text', + options: { + options: [ + { label: 'Text', value: 'text' }, + { label: 'JSON object', value: 'json_object' }, + ], + }, + }), + temperature: Property.Number({ + displayName: 'Temperature', + description: 'Sampling temperature between 0 and 1.5. Mistral recommends 0 to 0.7.', + required: false, + }), + max_tokens: Property.Number({ + displayName: 'Max Tokens', + description: 'Maximum number of tokens to generate.', + required: false, + }), + random_seed: Property.Number({ + displayName: 'Random Seed', + description: 'Set a seed to make repeated calls return the same output.', + required: false, + }), + }, + async run(context) { + const { model, system_prompt, messages, response_format, temperature, max_tokens, random_seed } = context.propsValue; + const conversation = (messages ?? []).filter(mistralApi.isRecord).map((message) => ({ + role: message['role'] === 'assistant' ? 'assistant' : 'user', + content: String(message['content'] ?? ''), + })); + if (conversation.length === 0) { + throw new Error('Provide at least one message.'); + } + const body = mistralApi.compact({ + model, + messages: system_prompt ? [{ role: 'system', content: system_prompt }, ...conversation] : conversation, + response_format: response_format && response_format !== 'text' ? { type: response_format } : undefined, + temperature, + max_tokens, + random_seed, + }); + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/chat/completions', + body, + timeout: 120000, + }); + const choice = response.choices[0]; + return { + id: response.id, + model: response.model, + content: mistralApi.contentToText(choice?.message?.content), + finish_reason: choice?.finish_reason ?? null, + prompt_tokens: response.usage?.prompt_tokens ?? null, + completion_tokens: response.usage?.completion_tokens ?? null, + total_tokens: response.usage?.total_tokens ?? null, + }; + }, +}); + +type ChatCompletionResponse = { + id: string; + model: string; + choices: { finish_reason: string; message?: { content: unknown } }[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/generate-speech.ts b/packages/pieces/community/mistral-ai/src/lib/actions/generate-speech.ts new file mode 100644 index 000000000000..c4f953f00781 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/generate-speech.ts @@ -0,0 +1,79 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { generateSpeechOutputSchema } from '../output-schemas'; + +export const generateSpeech = createAction({ + auth: mistralAuth, + name: 'generate_speech', + classification: 'READ', + displayName: 'Generate Speech', + description: 'Turn text into a spoken audio file.', + audience: 'ai', + aiMetadata: { + description: + 'Converts text into spoken audio with Mistral text-to-speech and returns it as a file in the chosen format (mp3 by default). A voice is required: a preset slug (default en_paul_neutral) or a custom voice UUID. Use Transcribe Audio for the reverse direction. Not idempotent: each call bills a new generation and produces a new file.', + idempotent: false, + }, + outputSchema: generateSpeechOutputSchema, + props: { + input: Property.LongText({ + displayName: 'Text', + description: 'The text to speak.', + required: true, + }), + model: Property.ShortText({ + displayName: 'Model', + description: 'Text-to-speech model id.', + required: true, + defaultValue: 'voxtral-mini-tts-latest', + }), + voice_id: Property.ShortText({ + displayName: 'Voice', + description: 'A preset voice slug such as en_paul_neutral, en_paul_happy or en_paul_sad, or the UUID of a custom voice.', + required: true, + defaultValue: 'en_paul_neutral', + }), + response_format: Property.StaticDropdown({ + displayName: 'Audio Format', + required: false, + defaultValue: 'mp3', + options: { + options: [ + { label: 'MP3', value: 'mp3' }, + { label: 'WAV', value: 'wav' }, + { label: 'FLAC', value: 'flac' }, + { label: 'Opus', value: 'opus' }, + { label: 'PCM', value: 'pcm' }, + ], + }, + }), + file_name: Property.ShortText({ + displayName: 'File Name', + description: 'Name for the audio file, without extension.', + required: false, + defaultValue: 'speech', + }), + }, + async run(context) { + const { input, model, voice_id, response_format, file_name } = context.propsValue; + const format = response_format ?? 'mp3'; + const response = await mistralApi.call<{ audio_data: string }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/audio/speech', + body: mistralApi.compact({ input, model, voice_id, response_format: format, stream: false }), + timeout: 300000, + }); + const data = Buffer.from(response.audio_data, 'base64'); + const fileName = `${file_name || 'speech'}.${format}`; + const file = await context.files.write({ fileName, data }); + return { + file, + file_name: fileName, + format, + size_bytes: data.byteLength, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-agent.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-agent.ts new file mode 100644 index 000000000000..0742189edb1c --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-agent.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { agentUtils, MistralAgent } from '../common/agents'; +import { agentOutputSchema } from '../output-schemas'; + +export const getAgent = createAction({ + auth: mistralAuth, + name: 'get_agent', + classification: 'READ', + displayName: 'Get Agent', + description: 'Get the configuration of a Mistral agent (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns one Mistral agent’s configuration (Beta) by agent id: model, instructions, tools, libraries and version numbers, optionally at a specific version. Get agent ids from List Agents or Create Agent. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: agentOutputSchema, + props: { + agent_id: Property.ShortText({ + displayName: 'Agent ID', + description: 'The agent id (usually starts with ag_), from List Agents.', + required: true, + }), + agent_version: Property.Number({ + displayName: 'Version', + description: 'Return this version instead of the current one.', + required: false, + }), + }, + async run(context) { + const { agent_id, agent_version } = context.propsValue; + const agent = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/agents/${encodeURIComponent(agent_id)}`, + queryParams: { agent_version }, + }); + return agentUtils.formatAgent(agent); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-batch-job.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-batch-job.ts new file mode 100644 index 000000000000..892b0a86bb5b --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-batch-job.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { batchUtils, MistralBatchJob } from '../common/batch'; +import { batchJobOutputSchema } from '../output-schemas'; + +export const getBatchJob = createAction({ + auth: mistralAuth, + name: 'get_batch_job', + classification: 'READ', + displayName: 'Get Batch Job', + description: 'Check the status and results of a batch job.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one batch job’s current status and request counts, plus the output and error file ids once it finishes; this is a single check, so call it again until the status is SUCCESS, FAILED, TIMEOUT_EXCEEDED or CANCELLED. Download results with Download File using the output file id, or enable Inline Results for small jobs. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: batchJobOutputSchema, + props: { + job_id: batchUtils.jobIdProp(), + inline: Property.Checkbox({ + displayName: 'Inline Results', + description: 'Include the results in the response instead of only an output file id. Only for small jobs.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { job_id, inline } = context.propsValue; + const job = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/batch/jobs/${encodeURIComponent(job_id)}`, + queryParams: { inline: inline ? true : undefined }, + }); + return batchUtils.formatJob(job); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-history.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-history.ts new file mode 100644 index 000000000000..20f9e4867294 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-history.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { ConversationEntry, conversationUtils } from '../common/conversations'; +import { conversationHistoryOutputSchema } from '../output-schemas'; + +export const getConversationHistory = createAction({ + auth: mistralAuth, + name: 'get_conversation_history', + classification: 'READ', + displayName: 'Get Conversation History', + description: 'Get every entry of a conversation, including tool calls (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns every entry of a stored conversation (Beta) in order: user and assistant messages plus tool executions, function calls and agent handoffs, each with its entry id. Use it to find the entry id Restart Conversation needs, or to audit what tools an agent ran; use Get Conversation Messages when you only need the messages. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: conversationHistoryOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + }, + async run(context) { + const response = await mistralApi.call<{ conversation_id: string; entries: ConversationEntry[] }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `/conversations/${encodeURIComponent(context.propsValue.conversation_id)}/history`, + }); + const entries = response.entries.map(conversationUtils.formatEntry); + return { conversation_id: response.conversation_id, entries, count: entries.length }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-messages.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-messages.ts new file mode 100644 index 000000000000..ca1bf7cc3bec --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation-messages.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { ConversationEntry, conversationUtils } from '../common/conversations'; +import { conversationMessagesOutputSchema } from '../output-schemas'; + +export const getConversationMessages = createAction({ + auth: mistralAuth, + name: 'get_conversation_messages', + classification: 'READ', + displayName: 'Get Conversation Messages', + description: 'Get the user and assistant messages of a conversation (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns only the user and assistant messages of a stored conversation (Beta), in order, as role and text. Use it to read the transcript; use Get Conversation History when you also need tool calls or entry ids. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: conversationMessagesOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + }, + async run(context) { + const response = await mistralApi.call<{ conversation_id: string; messages: ConversationEntry[] }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `/conversations/${encodeURIComponent(context.propsValue.conversation_id)}/messages`, + }); + const messages = response.messages.map(conversationUtils.formatEntry); + return { conversation_id: response.conversation_id, messages, count: messages.length }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation.ts new file mode 100644 index 000000000000..aefb59aa1300 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-conversation.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils, MistralConversation } from '../common/conversations'; +import { conversationOutputSchema } from '../output-schemas'; + +export const getConversation = createAction({ + auth: mistralAuth, + name: 'get_conversation', + classification: 'READ', + displayName: 'Get Conversation', + description: 'Get the details of a stored conversation (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns one stored conversation’s metadata (Beta): name, agent or model, instructions and timestamps. It does not include the messages; use Get Conversation Messages or Get Conversation History for those. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: conversationOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + }, + async run(context) { + const conversation = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/conversations/${encodeURIComponent(context.propsValue.conversation_id)}`, + }); + return conversationUtils.formatConversation(conversation); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-document-status.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-document-status.ts new file mode 100644 index 000000000000..4c94750f1904 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-document-status.ts @@ -0,0 +1,39 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils } from '../common/libraries'; +import { documentStatusOutputSchema } from '../output-schemas'; + +export const getDocumentStatus = createAction({ + auth: mistralAuth, + name: 'get_document_status', + classification: 'READ', + displayName: 'Get Document Status', + description: 'Check whether a library document has finished processing (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns the processing status of one library document (Beta), e.g. todo, in_progress, done or error, with an is_done flag. Poll it after Upload Library Document or Reprocess Document; one call is a single check, not a wait. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: documentStatusOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + }, + async run(context) { + const { library_id, document_id } = context.propsValue; + const status = await mistralApi.call<{ document_id: string; process_status: string }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `${libraryUtils.documentPath({ libraryId: library_id, documentId: document_id })}/status`, + }); + return { + document_id: status.document_id, + process_status: status.process_status, + is_done: status.process_status === 'done', + is_error: status.process_status === 'error', + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-document-text-content.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-document-text-content.ts new file mode 100644 index 000000000000..1cf77ed8c853 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-document-text-content.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils } from '../common/libraries'; +import { documentTextContentOutputSchema } from '../output-schemas'; + +export const getDocumentTextContent = createAction({ + auth: mistralAuth, + name: 'get_document_text_content', + classification: 'READ', + displayName: 'Get Document Text Content', + description: 'Get the text Mistral extracted from a library document (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns the text Mistral extracted from a processed library document (Beta), optionally limited to a page range. The document must have finished processing; check Get Document Status first. Use OCR instead for files that are not in a library. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: documentTextContentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + page_start: Property.Number({ displayName: 'First Page', required: false }), + page_end: Property.Number({ displayName: 'Last Page', required: false }), + }, + async run(context) { + const { library_id, document_id, page_start, page_end } = context.propsValue; + const response = await mistralApi.call<{ text: string }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `${libraryUtils.documentPath({ libraryId: library_id, documentId: document_id })}/text_content`, + queryParams: { page_start, page_end }, + }); + return { document_id, text: response.text, length: response.text.length }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-file-signed-url.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-file-signed-url.ts new file mode 100644 index 000000000000..74bd527b6c0a --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-file-signed-url.ts @@ -0,0 +1,48 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { getFileSignedUrlOutputSchema } from '../output-schemas'; + +export const getFileSignedUrl = createAction({ + auth: mistralAuth, + name: 'get_file_signed_url', + classification: 'READ', + displayName: 'Get File Download URL', + description: 'Get a temporary link to download an uploaded file.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a time-limited signed URL (1 to 168 hours, default 24) that downloads one uploaded file without an API key. Use it to hand a file to another service by link; use Download File when the flow itself needs the bytes. Each call issues a new URL, but nothing is changed in the account.', + idempotent: true, + }, + outputSchema: getFileSignedUrlOutputSchema, + props: { + file_id: Property.ShortText({ + displayName: 'File ID', + description: 'The file UUID, from List Files.', + required: true, + }), + expiry_hours: Property.Number({ + displayName: 'Expiry (hours)', + description: 'How long the link stays valid, between 1 and 168 hours.', + required: false, + defaultValue: 24, + }), + }, + async run(context) { + const { file_id, expiry_hours } = context.propsValue; + const expiry = expiry_hours ?? 24; + const response = await mistralApi.call<{ url: string }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `/files/${encodeURIComponent(file_id)}/url`, + queryParams: { expiry }, + }); + return { + file_id, + url: response.url, + expires_at: new Date(Date.now() + expiry * 3600 * 1000).toISOString(), + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-file.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-file.ts new file mode 100644 index 000000000000..94e337ec8aed --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-file.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { fileUtils, MistralFile } from '../common/files'; +import { fileOutputSchema } from '../output-schemas'; + +export const getFile = createAction({ + auth: mistralAuth, + name: 'get_file', + classification: 'READ', + displayName: 'Get File', + description: 'Get the details of an uploaded file.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the metadata of one uploaded file by UUID: name, purpose, size, MIME type, source and creation time. It does not return the content; use Download File for that. Get file ids from List Files or Upload File. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: fileOutputSchema, + props: { + file_id: Property.ShortText({ + displayName: 'File ID', + description: 'The file UUID, from List Files or Upload File.', + required: true, + }), + }, + async run(context) { + const file = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/files/${encodeURIComponent(context.propsValue.file_id)}`, + }); + return fileUtils.formatFile(file); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-library-document.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-library-document.ts new file mode 100644 index 000000000000..db6cc8d93fdb --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-library-document.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralDocument } from '../common/libraries'; +import { documentOutputSchema } from '../output-schemas'; + +export const getLibraryDocument = createAction({ + auth: mistralAuth, + name: 'get_library_document', + classification: 'READ', + displayName: 'Get Library Document', + description: 'Get the details of a document in a library (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns one library document’s metadata (Beta): name, type, size, page count, summary, attributes and processing status. Use Get Document Text Content for the text itself. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: documentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + }, + async run(context) { + const { library_id, document_id } = context.propsValue; + const document = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: libraryUtils.documentPath({ libraryId: library_id, documentId: document_id }), + }); + return libraryUtils.formatDocument(document); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-library.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-library.ts new file mode 100644 index 000000000000..f1d4ae053294 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-library.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralLibrary } from '../common/libraries'; +import { libraryOutputSchema } from '../output-schemas'; + +export const getLibrary = createAction({ + auth: mistralAuth, + name: 'get_library', + classification: 'READ', + displayName: 'Get Library', + description: 'Get the details of a document library (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Returns one document library’s details (Beta) by UUID: name, description, owner, document count, total size and chunk size. Use List Library Documents to see its documents. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: libraryOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + }, + async run(context) { + const library = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/libraries/${encodeURIComponent(context.propsValue.library_id)}`, + }); + return libraryUtils.formatLibrary(library); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/get-model.ts b/packages/pieces/community/mistral-ai/src/lib/actions/get-model.ts new file mode 100644 index 000000000000..0deada122791 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/get-model.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { getModelOutputSchema } from '../output-schemas'; + +export const getModel = createAction({ + auth: mistralAuth, + name: 'get_model', + classification: 'READ', + displayName: 'Get Model', + description: 'Get details and capabilities of one Mistral model.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one model’s details by id: capabilities (chat, function calling, vision, fine-tuning, OCR, audio), context length, aliases, owner and deprecation date. Use it to check what a model supports before calling it; use List Models to discover ids. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getModelOutputSchema, + props: { + model_id: Property.ShortText({ + displayName: 'Model ID', + description: 'The model id, e.g. mistral-large-latest or a fine-tuned id starting with ft:.', + required: true, + }), + }, + async run(context) { + const model = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: `/models/${encodeURIComponent(context.propsValue.model_id)}`, + }); + return { + id: model.id, + name: model.name ?? null, + description: model.description ?? null, + type: model.type ?? null, + owned_by: model.owned_by ?? null, + created: model.created ?? null, + max_context_length: model.max_context_length ?? null, + aliases: model.aliases ?? [], + deprecation: model.deprecation ?? null, + default_model_temperature: model.default_model_temperature ?? null, + capabilities: model.capabilities ?? {}, + }; + }, +}); + +type ModelCard = { + id: string; + name?: string | null; + description?: string | null; + type?: string; + owned_by?: string; + created?: number; + max_context_length?: number; + aliases?: string[]; + deprecation?: string | null; + default_model_temperature?: number | null; + capabilities?: Record; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-agents.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-agents.ts new file mode 100644 index 000000000000..9a25a441de60 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-agents.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { agentUtils, MistralAgent } from '../common/agents'; +import { listAgentsOutputSchema } from '../output-schemas'; + +export const listAgents = createAction({ + auth: mistralAuth, + name: 'list_agents', + classification: 'SEARCH', + displayName: 'List Agents', + description: 'List the Mistral agents in your organization (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Lists Mistral agents (Beta) with their ids, names, models, instructions and tools, optionally filtered by a name or id search, one page at a time. Use it to find the agent id for Start Conversation, Get Agent, Update Agent or Delete Agent. Pass the returned next page token to get the next page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listAgentsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only return agents whose name or id contains this text.', + required: false, + }), + page_size: Property.Number({ displayName: 'Page Size', required: false, defaultValue: 20 }), + page_token: Property.ShortText({ + displayName: 'Page Token', + description: 'The next page token from a previous call.', + required: false, + }), + }, + async run(context) { + const { search, page_size, page_token } = context.propsValue; + const response = await mistralApi.call<{ data: MistralAgent[]; next_page_token?: string | null }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/agents/pages', + queryParams: { search, page_size, page_token }, + }); + const agents = response.data.map(agentUtils.formatAgent); + return { + agents, + count: agents.length, + next_page_token: response.next_page_token ?? null, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-batch-jobs.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-batch-jobs.ts new file mode 100644 index 000000000000..7a3b827565f0 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-batch-jobs.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { BATCH_STATUS_OPTIONS, batchUtils, MistralBatchJob } from '../common/batch'; +import { listBatchJobsOutputSchema } from '../output-schemas'; + +export const listBatchJobs = createAction({ + auth: mistralAuth, + name: 'list_batch_jobs', + classification: 'SEARCH', + displayName: 'List Batch Jobs', + description: 'List batch inference jobs.', + audience: 'ai', + aiMetadata: { + description: + 'Lists batch inference jobs with their UUIDs, status, endpoint, model and request counts, newest first, optionally filtered by status or model, one page at a time. Use it to find a job id for Get Batch Job or Cancel Batch Job. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listBatchJobsOutputSchema, + props: { + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { options: BATCH_STATUS_OPTIONS }, + }), + model: Property.ShortText({ displayName: 'Model', required: false }), + page: Property.Number({ displayName: 'Page', description: 'Zero-based page number.', required: false, defaultValue: 0 }), + page_size: Property.Number({ displayName: 'Page Size', required: false, defaultValue: 100 }), + }, + async run(context) { + const { status, model, page, page_size } = context.propsValue; + const response = await mistralApi.call<{ data?: MistralBatchJob[]; total: number }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/batch/jobs', + queryParams: { status, model, page, page_size, order_by: '-created' }, + }); + const jobs = (response.data ?? []).map(batchUtils.formatJob); + return { jobs, count: jobs.length, total: response.total }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-conversations.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-conversations.ts new file mode 100644 index 000000000000..c04f4dbebdf7 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-conversations.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils, MistralConversation } from '../common/conversations'; +import { listConversationsOutputSchema } from '../output-schemas'; + +export const listConversations = createAction({ + auth: mistralAuth, + name: 'list_conversations', + classification: 'SEARCH', + displayName: 'List Conversations', + description: 'List stored conversations (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Lists stored Mistral conversations (Beta) with their ids, names, agent or model, and timestamps, one page at a time. Use it to find a conversation id for Append, Restart, History, Messages or Delete. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listConversationsOutputSchema, + props: { + page: Property.Number({ displayName: 'Page', description: 'Zero-based page number.', required: false, defaultValue: 0 }), + page_size: Property.Number({ displayName: 'Page Size', required: false, defaultValue: 100 }), + }, + async run(context) { + const { page, page_size } = context.propsValue; + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.GET, + path: '/conversations', + queryParams: { page, page_size }, + }); + const conversations = response.map(conversationUtils.formatConversation); + return { conversations, count: conversations.length }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-files.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-files.ts new file mode 100644 index 000000000000..eb52f0a10216 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-files.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { FILE_PURPOSE_OPTIONS, fileUtils, MistralFile } from '../common/files'; +import { listFilesOutputSchema } from '../output-schemas'; + +export const listFiles = createAction({ + auth: mistralAuth, + name: 'list_files', + classification: 'SEARCH', + displayName: 'List Files', + description: 'List files uploaded to your Mistral account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists files stored in the Mistral account with their UUIDs, names, purpose, size and creation time, optionally filtered by purpose or a filename search, one page at a time. Use it to find a file id for Get File, Delete File, Download File, OCR, transcription or a batch job. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listFilesOutputSchema, + props: { + purpose: Property.StaticDropdown({ + displayName: 'Purpose', + description: 'Only return files uploaded for this purpose.', + required: false, + options: { options: FILE_PURPOSE_OPTIONS }, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only return files whose name contains this text.', + required: false, + }), + page: Property.Number({ + displayName: 'Page', + description: 'Zero-based page number.', + required: false, + defaultValue: 0, + }), + page_size: Property.Number({ + displayName: 'Page Size', + required: false, + defaultValue: 100, + }), + }, + async run(context) { + const { purpose, search, page, page_size } = context.propsValue; + const response = await mistralApi.call<{ data: MistralFile[]; total?: number | null }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/files', + queryParams: { purpose, search, page, page_size, include_total: true }, + }); + const files = response.data.map(fileUtils.formatFile); + return { + files, + count: files.length, + total: response.total ?? null, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-libraries.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-libraries.ts new file mode 100644 index 000000000000..9b830ef1911d --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-libraries.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralLibrary } from '../common/libraries'; +import { listLibrariesOutputSchema } from '../output-schemas'; + +export const listLibraries = createAction({ + auth: mistralAuth, + name: 'list_libraries', + classification: 'SEARCH', + displayName: 'List Libraries', + description: 'List document libraries you can access (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Lists Mistral document libraries (Beta) with their UUIDs, names, document counts and sizes, optionally filtered by a name search, one page at a time. Use it to find a library id for the document actions or for an agent’s document library tool. Pass the returned next page token to get the next page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listLibrariesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only return libraries whose name contains this text.', + required: false, + }), + page_size: Property.Number({ displayName: 'Page Size', required: false, defaultValue: 100 }), + page_token: Property.ShortText({ + displayName: 'Page Token', + description: 'The next page token from a previous call.', + required: false, + }), + }, + async run(context) { + const { search, page_size, page_token } = context.propsValue; + const response = await mistralApi.call<{ data: MistralLibrary[]; next_page_token?: string | null }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/libraries', + queryParams: { search, page_size, page_token }, + }); + const libraries = response.data.map(libraryUtils.formatLibrary); + return { + libraries, + count: libraries.length, + next_page_token: response.next_page_token ?? null, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-library-documents.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-library-documents.ts new file mode 100644 index 000000000000..a9f92c6ca7bc --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-library-documents.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralDocument } from '../common/libraries'; +import { listLibraryDocumentsOutputSchema } from '../output-schemas'; + +export const listLibraryDocuments = createAction({ + auth: mistralAuth, + name: 'list_library_documents', + classification: 'SEARCH', + displayName: 'List Library Documents', + description: 'List the documents in a library (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Lists the documents in one library (Beta) with their UUIDs, names, sizes, summaries and processing status, optionally filtered by a name search, one page at a time. Use it to find a document id for the other document actions. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listLibraryDocumentsOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only return documents whose name contains this text.', + required: false, + }), + page: Property.Number({ displayName: 'Page', description: 'Zero-based page number.', required: false, defaultValue: 0 }), + page_size: Property.Number({ displayName: 'Page Size', required: false, defaultValue: 100 }), + }, + async run(context) { + const { library_id, search, page, page_size } = context.propsValue; + const response = await mistralApi.call<{ data: MistralDocument[]; pagination?: { total_items?: number; has_more?: boolean } }>({ + auth: context.auth, + method: HttpMethod.GET, + path: `/libraries/${encodeURIComponent(library_id)}/documents`, + queryParams: { search, page, page_size }, + }); + const documents = response.data.map(libraryUtils.formatDocument); + return { + documents, + count: documents.length, + total: response.pagination?.total_items ?? null, + has_more: response.pagination?.has_more ?? null, + }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts index 2c296e0f366c..dc6f245c6f95 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts @@ -3,6 +3,7 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import { parseMistralError } from '../common/props'; import { mistralRequest } from '../common/request'; +import { listModelsOutputSchema } from '../output-schemas'; export const listModels = createAction({ audience: 'both', @@ -12,6 +13,7 @@ export const listModels = createAction({ displayName: 'List Models', description: 'Retrieves a list of available Mistral AI models.', aiMetadata: { description: 'Returns every model the connected Mistral key can access, chat, embedding and OCR ids alike, exactly as the models endpoint reports them. Use it to discover a valid model id before calling Ask Mistral or Run OCR, both of which require one; it is the only discovery action here and takes no inputs, so it cannot filter or search. Read-only and idempotent.', idempotent: true }, + outputSchema: listModelsOutputSchema, props: {}, async run({ auth }) { try { diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/moderate-chat.ts b/packages/pieces/community/mistral-ai/src/lib/actions/moderate-chat.ts new file mode 100644 index 000000000000..c8b022e9c75a --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/moderate-chat.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { moderationUtils, ModerationResponse } from '../common/moderation'; +import { moderateChatOutputSchema } from '../output-schemas'; + +export const moderateChat = createAction({ + auth: mistralAuth, + name: 'moderate_chat', + classification: 'READ', + displayName: 'Moderate Chat', + description: 'Check a conversation for harmful content.', + audience: 'ai', + aiMetadata: { + description: + 'Classifies a conversation (user and assistant turns) against Mistral’s moderation categories, judging the last turn in the context of the earlier ones, and returns flags, scores and the flagged categories. Use this for chat transcripts; use Moderate Text for standalone strings. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: moderateChatOutputSchema, + props: { + messages: Property.Array({ + displayName: 'Messages', + description: 'The conversation to check, oldest first.', + required: true, + properties: { + role: Property.StaticDropdown({ + displayName: 'Role', + required: true, + defaultValue: 'user', + options: { + options: [ + { label: 'User', value: 'user' }, + { label: 'Assistant', value: 'assistant' }, + { label: 'System', value: 'system' }, + ], + }, + }), + content: Property.LongText({ displayName: 'Content', required: true }), + }, + }), + model: Property.ShortText({ + displayName: 'Model', + required: true, + defaultValue: 'mistral-moderation-latest', + }), + }, + async run(context) { + const messages = (context.propsValue.messages ?? []).filter(mistralApi.isRecord).map((message) => ({ + role: String(message['role'] ?? 'user'), + content: String(message['content'] ?? ''), + })); + if (messages.length === 0) { + throw new Error('Provide at least one message.'); + } + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/chat/moderations', + body: { model: context.propsValue.model, input: messages }, + }); + return moderationUtils.formatResponse({ response, inputs: [messages[messages.length - 1].content] }); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/moderate-text.ts b/packages/pieces/community/mistral-ai/src/lib/actions/moderate-text.ts new file mode 100644 index 000000000000..b67ab7076c8c --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/moderate-text.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { moderationUtils, ModerationResponse } from '../common/moderation'; +import { moderateTextOutputSchema } from '../output-schemas'; + +export const moderateText = createAction({ + auth: mistralAuth, + name: 'moderate_text', + classification: 'READ', + displayName: 'Moderate Text', + description: 'Check one or more texts for harmful content.', + audience: 'ai', + aiMetadata: { + description: + 'Classifies one or more plain texts against Mistral’s moderation categories (sexual, hate, violence, self-harm, PII and more) and returns per-text flags and scores plus the list of flagged categories. Use this for standalone text; use Moderate Chat when the content is a conversation so the assistant turn is judged in context. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: moderateTextOutputSchema, + props: { + texts: Property.Array({ + displayName: 'Texts', + description: 'One or more texts to check. Each is scored separately.', + required: true, + }), + model: Property.ShortText({ + displayName: 'Model', + required: true, + defaultValue: 'mistral-moderation-latest', + }), + }, + async run(context) { + const texts = mistralApi.toStringArray(context.propsValue.texts); + if (texts.length === 0) { + throw new Error('Provide at least one text.'); + } + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/moderations', + body: { model: context.propsValue.model, input: texts }, + }); + return moderationUtils.formatResponse({ response, inputs: texts }); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/reprocess-document.ts b/packages/pieces/community/mistral-ai/src/lib/actions/reprocess-document.ts new file mode 100644 index 000000000000..a6fa2a5eaa70 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/reprocess-document.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils } from '../common/libraries'; +import { reprocessDocumentOutputSchema } from '../output-schemas'; + +export const reprocessDocument = createAction({ + auth: mistralAuth, + name: 'reprocess_document', + classification: 'WRITE', + displayName: 'Reprocess Document', + description: 'Run text extraction and indexing again for a library document (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Queues a library document (Beta) to be processed again, re-extracting and re-indexing its text, for example after a processing error. It returns once queued; poll Get Document Status for the result. Not idempotent: each call starts a new processing run.', + idempotent: false, + }, + outputSchema: reprocessDocumentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + }, + async run(context) { + const { library_id, document_id } = context.propsValue; + await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: `${libraryUtils.documentPath({ libraryId: library_id, documentId: document_id })}/reprocess`, + }); + return { library_id, document_id, reprocessing: true }; + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/restart-conversation.ts b/packages/pieces/community/mistral-ai/src/lib/actions/restart-conversation.ts new file mode 100644 index 000000000000..ee7c035d766b --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/restart-conversation.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils, ConversationResponse } from '../common/conversations'; +import { conversationReplyOutputSchema } from '../output-schemas'; + +export const restartConversation = createAction({ + auth: mistralAuth, + name: 'restart_conversation', + classification: 'WRITE', + displayName: 'Restart Conversation', + description: 'Branch a conversation from an earlier entry with a new message (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new conversation branch (Beta) that keeps the original history up to a chosen entry, then sends a new message from that point and returns the reply and the new conversation id; the original conversation is left unchanged. The entry id is required; get it from Get Conversation History. Use Append to Conversation to simply continue. Not idempotent: each call creates a new branch.', + idempotent: false, + }, + outputSchema: conversationReplyOutputSchema, + props: { + conversation_id: conversationUtils.conversationIdProp(), + from_entry_id: Property.ShortText({ + displayName: 'From Entry ID', + description: 'The entry to restart from, from Get Conversation History.', + required: true, + }), + message: Property.LongText({ + displayName: 'Message', + description: 'The new user message to send from that point.', + required: true, + }), + }, + async run(context) { + const { conversation_id, from_entry_id, message } = context.propsValue; + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: `/conversations/${encodeURIComponent(conversation_id)}/restart`, + body: { inputs: message, from_entry_id, stream: false, store: true }, + timeout: 300000, + }); + return conversationUtils.formatResponse(response); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/start-conversation.ts b/packages/pieces/community/mistral-ai/src/lib/actions/start-conversation.ts new file mode 100644 index 000000000000..2ff7a1428f51 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/start-conversation.ts @@ -0,0 +1,75 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { conversationUtils, ConversationResponse } from '../common/conversations'; +import { conversationReplyOutputSchema } from '../output-schemas'; + +export const startConversation = createAction({ + auth: mistralAuth, + name: 'start_conversation', + classification: 'WRITE', + displayName: 'Start Conversation', + description: 'Start a stored conversation with a Mistral agent or model (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Starts a new server-side conversation (Beta) with either a Mistral agent (agent id) or a plain model, sends the first message and returns the reply text, all output entries (including tool calls) and the conversation id. This is how you run a Mistral agent; continue with Append to Conversation. Use Generate Chat Completion instead for one-off stateless replies. Not idempotent: each call creates a new conversation and bills a new reply.', + idempotent: false, + }, + outputSchema: conversationReplyOutputSchema, + props: { + message: Property.LongText({ + displayName: 'Message', + description: 'The first user message.', + required: true, + }), + agent_id: Property.ShortText({ + displayName: 'Agent ID', + description: 'Run this agent (from List Agents). Leave empty to talk to a model directly.', + required: false, + }), + model: Property.ShortText({ + displayName: 'Model', + description: 'Model id to use when no agent is given, e.g. mistral-medium-latest.', + required: false, + }), + instructions: Property.LongText({ + displayName: 'Instructions', + description: 'System instructions, only used when talking to a model directly.', + required: false, + }), + name: Property.ShortText({ displayName: 'Conversation Name', required: false }), + store: Property.Checkbox({ + displayName: 'Store Conversation', + description: 'Keep the conversation on Mistral so it can be continued later.', + required: false, + defaultValue: true, + }), + }, + async run(context) { + const { message, agent_id, model, instructions, name, store } = context.propsValue; + if (!agent_id && !model) { + throw new Error('Provide either an Agent ID or a Model.'); + } + if (agent_id && model) { + throw new Error('Provide only one of Agent ID or Model.'); + } + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/conversations', + body: mistralApi.compact({ + inputs: message, + agent_id, + model, + instructions: agent_id ? undefined : instructions, + name, + store: store ?? true, + stream: false, + }), + timeout: 300000, + }); + return conversationUtils.formatResponse(response); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/transcribe-audio.ts b/packages/pieces/community/mistral-ai/src/lib/actions/transcribe-audio.ts new file mode 100644 index 000000000000..42db57641742 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/transcribe-audio.ts @@ -0,0 +1,126 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { transcribeAudioOutputSchema } from '../output-schemas'; + +export const transcribeAudio = createAction({ + auth: mistralAuth, + name: 'transcribe_audio', + classification: 'READ', + displayName: 'Transcribe Audio', + description: 'Convert speech in an audio file to text with a Voxtral model.', + audience: 'ai', + aiMetadata: { + description: + 'Transcribes speech to text with a Mistral Voxtral model and returns the full text, detected language and optional timestamped segments. Provide exactly one source: an audio file, a public audio URL, or the id of a file already uploaded to Mistral. Setting the language improves accuracy; diarization labels speakers. Read-only, stores nothing; safe to retry.', + idempotent: true, + }, + outputSchema: transcribeAudioOutputSchema, + props: { + model: Property.StaticDropdown({ + displayName: 'Model', + required: true, + defaultValue: 'voxtral-mini-latest', + options: { + options: [ + { label: 'voxtral-mini-latest', value: 'voxtral-mini-latest' }, + { label: 'voxtral-mini-2507', value: 'voxtral-mini-2507' }, + ], + }, + }), + file: Property.File({ + displayName: 'Audio File', + description: 'The audio file to transcribe. Leave empty if you use Audio URL or File ID.', + required: false, + }), + file_url: Property.ShortText({ + displayName: 'Audio URL', + description: 'Public URL of the audio file.', + required: false, + }), + file_id: Property.ShortText({ + displayName: 'File ID', + description: 'UUID of an audio file already uploaded to Mistral (see List Files).', + required: false, + }), + language: Property.ShortText({ + displayName: 'Language', + description: 'Two-letter language code of the audio, e.g. en or fr. Leave empty to auto-detect.', + required: false, + }), + diarize: Property.Checkbox({ + displayName: 'Identify Speakers', + description: 'Label which speaker said each segment.', + required: false, + defaultValue: false, + }), + timestamp_granularity: Property.StaticDropdown({ + displayName: 'Timestamps', + required: false, + options: { + options: [ + { label: 'Segment', value: 'segment' }, + { label: 'Word', value: 'word' }, + ], + }, + }), + }, + async run(context) { + const { model, file, file_url, file_id, language, diarize, timestamp_granularity } = context.propsValue; + const sources = [file, file_url, file_id].filter((source) => source !== undefined && source !== null && source !== ''); + if (sources.length !== 1) { + throw new Error('Provide exactly one of Audio File, Audio URL or File ID.'); + } + const form = new FormData(); + form.append('model', model); + if (file) { + form.append('file', Buffer.from(file.data), file.filename); + } + if (file_url) { + form.append('file_url', file_url); + } + if (file_id) { + form.append('file_id', file_id); + } + if (language) { + form.append('language', language); + } + if (diarize) { + form.append('diarize', 'true'); + } + if (timestamp_granularity) { + form.append('timestamp_granularities', timestamp_granularity); + } + const response = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: '/audio/transcriptions', + headers: form.getHeaders(), + body: form, + timeout: 600000, + }); + return { + model: response.model, + text: response.text, + language: response.language ?? null, + segments: (response.segments ?? []).map((segment) => ({ + text: segment.text, + start: segment.start, + end: segment.end, + speaker_id: segment.speaker_id ?? null, + })), + prompt_audio_seconds: response.usage?.prompt_audio_seconds ?? null, + total_tokens: response.usage?.total_tokens ?? null, + }; + }, +}); + +type TranscriptionResponse = { + model: string; + text: string; + language: string | null; + segments?: { text: string; start: number; end: number; speaker_id?: string | null }[]; + usage?: { prompt_audio_seconds?: number | null; total_tokens?: number }; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/update-agent.ts b/packages/pieces/community/mistral-ai/src/lib/actions/update-agent.ts new file mode 100644 index 000000000000..2113f07000e3 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/update-agent.ts @@ -0,0 +1,86 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { AgentTool, agentUtils, MistralAgent } from '../common/agents'; +import { MistralAuthValue } from '../common/request'; +import { agentOutputSchema } from '../output-schemas'; + +export const updateAgent = createAction({ + auth: mistralAuth, + name: 'update_agent', + classification: 'WRITE', + displayName: 'Update Agent', + description: 'Change a Mistral agent’s name, model, instructions or tools (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Updates a Mistral agent (Beta) by agent id; only the fields you pass change and the rest are kept. Built-in tools and document libraries are replaced independently: passing one keeps the other as it was. Tools cannot be cleared here. Each update creates a new agent version. Get the id from List Agents. Idempotent: sending the same values again leaves the same configuration.', + idempotent: true, + }, + outputSchema: agentOutputSchema, + props: { + agent_id: Property.ShortText({ + displayName: 'Agent ID', + description: 'The agent id (usually starts with ag_), from List Agents.', + required: true, + }), + name: Property.ShortText({ displayName: 'Name', required: false }), + model: Property.ShortText({ displayName: 'Model', required: false }), + instructions: Property.LongText({ displayName: 'Instructions', required: false }), + description: Property.ShortText({ displayName: 'Description', required: false }), + ...agentUtils.toolsProps(), + temperature: Property.Number({ displayName: 'Temperature', required: false }), + }, + async run(context) { + const { agent_id, name, model, instructions, description, built_in_tools, library_ids, temperature } = context.propsValue; + const body = mistralApi.compact({ + name, + model, + instructions, + description, + tools: await resolveTools({ + auth: context.auth, + agentId: agent_id, + builtInTools: mistralApi.toStringArray(built_in_tools), + libraryIds: mistralApi.toStringArray(library_ids), + }), + completion_args: temperature === undefined || temperature === null ? undefined : { temperature }, + }); + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to update.'); + } + const agent = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.PATCH, + path: `/agents/${encodeURIComponent(agent_id)}`, + body, + }); + return agentUtils.formatAgent(agent); + }, +}); + +async function resolveTools({ auth, agentId, builtInTools, libraryIds }: ResolveToolsParams): Promise { + if (builtInTools.length === 0 && libraryIds.length === 0) { + return undefined; + } + const updated = agentUtils.buildTools({ builtInTools, libraryIds }) ?? []; + if (builtInTools.length > 0 && libraryIds.length > 0) { + return updated; + } + const current = await mistralApi.call({ + auth, + method: HttpMethod.GET, + path: `/agents/${encodeURIComponent(agentId)}`, + }); + const keepLibraries = libraryIds.length === 0; + const kept = (current.tools ?? []).filter((tool) => (tool.type === 'document_library') === keepLibraries); + return [...updated, ...kept]; +} + +type ResolveToolsParams = { + auth: MistralAuthValue; + agentId: string; + builtInTools: string[]; + libraryIds: string[]; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/update-library-document.ts b/packages/pieces/community/mistral-ai/src/lib/actions/update-library-document.ts new file mode 100644 index 000000000000..402a544c70e6 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/update-library-document.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralDocument } from '../common/libraries'; +import { documentOutputSchema } from '../output-schemas'; + +export const updateLibraryDocument = createAction({ + auth: mistralAuth, + name: 'update_library_document', + classification: 'WRITE', + displayName: 'Update Library Document', + description: 'Rename a library document, set its attributes or an expiry date (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Updates a library document’s name, custom attributes and/or automatic-deletion date (Beta); a field you leave empty keeps its current value, but passing attributes replaces the whole attribute set. It does not change the file content. Idempotent: sending the same values again leaves the same document.', + idempotent: true, + }, + outputSchema: documentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + document_id: libraryUtils.documentIdProp(), + name: Property.ShortText({ displayName: 'Name', required: false }), + attributes: Property.Json({ + displayName: 'Attributes', + description: 'Custom key-value attributes as a JSON object. Replaces the existing attributes.', + required: false, + }), + expires_at: Property.DateTime({ + displayName: 'Delete After', + description: 'The document is deleted automatically after this date.', + required: false, + }), + }, + async run(context) { + const { library_id, document_id, name, attributes, expires_at } = context.propsValue; + const body = mistralApi.compact({ + name, + attributes: mistralApi.parseJsonInput({ value: attributes, fieldName: 'Attributes' }), + expires_at, + }); + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to update.'); + } + const document = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.PATCH, + path: libraryUtils.documentPath({ libraryId: library_id, documentId: document_id }), + body, + }); + return libraryUtils.formatDocument(document); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/update-library.ts b/packages/pieces/community/mistral-ai/src/lib/actions/update-library.ts new file mode 100644 index 000000000000..bec3b5df8f59 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/update-library.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralLibrary } from '../common/libraries'; +import { libraryOutputSchema } from '../output-schemas'; + +export const updateLibrary = createAction({ + auth: mistralAuth, + name: 'update_library', + classification: 'WRITE', + displayName: 'Update Library', + description: 'Rename a document library or change its description (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Updates a document library’s name and/or description (Beta) by UUID; a field you leave empty keeps its current value. Idempotent: sending the same values again leaves the same library.', + idempotent: true, + }, + outputSchema: libraryOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + name: Property.ShortText({ displayName: 'Name', required: false }), + description: Property.LongText({ displayName: 'Description', required: false }), + }, + async run(context) { + const { library_id, name, description } = context.propsValue; + const body = mistralApi.compact({ name, description }); + if (Object.keys(body).length === 0) { + throw new Error('Provide a name or a description to update.'); + } + const library = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.PATCH, + path: `/libraries/${encodeURIComponent(library_id)}`, + body, + }); + return libraryUtils.formatLibrary(library); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts b/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts index 61217ac39b4f..6fe6824bde02 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts @@ -4,6 +4,7 @@ import { mistralAuth } from '../common/auth'; import FormData from 'form-data'; import { parseMistralError } from '../common/props'; import { mistralRequest } from '../common/request'; +import { uploadFileOutputSchema } from '../output-schemas'; const SUPPORTED_PURPOSES = ['fine-tune', 'batch', 'ocr']; const MAX_FILE_SIZE_BYTES = 512 * 1024 * 1024; @@ -33,6 +34,7 @@ export const uploadFile = createAction({ displayName: 'Upload File', description: 'Upload a file to Mistral AI (e.g., for fine-tuning or context storage).', aiMetadata: { description: 'Stores a file in the connected Mistral account and returns its file id, tagged with a purpose of fine-tune, batch, or ocr that decides what the id may later be used for. Run it before Run OCR whenever the document is a step attachment rather than a public URL: upload with purpose=ocr and feed the returned id into that action. Rejects files above 512MB or with an extension outside jsonl, txt, csv, pdf, docx, png, jpg, jpeg, mp3, mp4; not idempotent: each call stores another copy and returns a new id.', idempotent: false }, + outputSchema: uploadFileOutputSchema, props: { file: Property.File({ displayName: 'File', diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/upload-library-document.ts b/packages/pieces/community/mistral-ai/src/lib/actions/upload-library-document.ts new file mode 100644 index 000000000000..46ea154e14ad --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/upload-library-document.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { mistralAuth } from '../common/auth'; +import { mistralApi } from '../common/client'; +import { libraryUtils, MistralDocument } from '../common/libraries'; +import { documentOutputSchema } from '../output-schemas'; + +export const uploadLibraryDocument = createAction({ + auth: mistralAuth, + name: 'upload_library_document', + classification: 'WRITE', + displayName: 'Upload Library Document', + description: 'Add a document to a library (Beta).', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a file into a Mistral document library (Beta) and returns the new document’s UUID and processing status. Processing is asynchronous: poll Get Document Status until it is done before relying on it in an agent or reading Get Document Text Content. Not idempotent: each call adds another document.', + idempotent: false, + }, + outputSchema: documentOutputSchema, + props: { + library_id: libraryUtils.libraryIdProp(), + file: Property.File({ + displayName: 'File', + description: 'The document to add, e.g. a PDF, DOCX or text file.', + required: true, + }), + }, + async run(context) { + const { library_id, file } = context.propsValue; + const form = new FormData(); + form.append('file', Buffer.from(file.data), file.filename); + const document = await mistralApi.call({ + auth: context.auth, + method: HttpMethod.POST, + path: `/libraries/${encodeURIComponent(library_id)}/documents`, + headers: form.getHeaders(), + body: form, + timeout: 300000, + }); + return libraryUtils.formatDocument(document); + }, +}); diff --git a/packages/pieces/community/mistral-ai/src/lib/common/agents.ts b/packages/pieces/community/mistral-ai/src/lib/common/agents.ts new file mode 100644 index 000000000000..9f0b7c22f77e --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/agents.ts @@ -0,0 +1,69 @@ +import { Property } from '@activepieces/pieces-framework'; + +function buildTools({ builtInTools, libraryIds }: { builtInTools: string[]; libraryIds: string[] }): AgentTool[] | undefined { + const tools: AgentTool[] = [ + ...builtInTools.map((type) => ({ type })), + ...(libraryIds.length > 0 ? [{ type: 'document_library', library_ids: libraryIds }] : []), + ]; + return tools.length > 0 ? tools : undefined; +} + +function formatAgent(agent: MistralAgent) { + return { + id: agent.id, + name: agent.name, + description: agent.description ?? null, + model: agent.model, + instructions: agent.instructions ?? null, + tools: (agent.tools ?? []).map((tool) => tool.type), + library_ids: (agent.tools ?? []).flatMap((tool) => tool.library_ids ?? []), + temperature: agent.completion_args?.temperature ?? null, + version: agent.version, + versions: agent.versions ?? [], + source: agent.source ?? null, + created_at: agent.created_at, + updated_at: agent.updated_at, + }; +} + +function toolsProps() { + return { + built_in_tools: Property.StaticMultiSelectDropdown({ + displayName: 'Built-in Tools', + description: 'Mistral-hosted tools the agent may use.', + required: false, + options: { + options: [ + { label: 'Web Search', value: 'web_search' }, + { label: 'Web Search (Premium)', value: 'web_search_premium' }, + { label: 'Code Interpreter', value: 'code_interpreter' }, + { label: 'Image Generation', value: 'image_generation' }, + ], + }, + }), + library_ids: Property.Array({ + displayName: 'Document Library IDs', + description: 'Library UUIDs (from List Libraries) the agent can search for answers.', + required: false, + }), + }; +} + +export const agentUtils = { buildTools, formatAgent, toolsProps }; + +export type AgentTool = { type: string; library_ids?: string[] }; + +export type MistralAgent = { + id: string; + name: string; + description?: string | null; + model: string; + instructions?: string | null; + tools?: AgentTool[]; + completion_args?: { temperature?: number | null }; + version: number; + versions?: number[]; + source?: string; + created_at: string; + updated_at: string; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/batch.ts b/packages/pieces/community/mistral-ai/src/lib/common/batch.ts new file mode 100644 index 000000000000..1e8af00295da --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/batch.ts @@ -0,0 +1,79 @@ +import { Property } from '@activepieces/pieces-framework'; +import { fileUtils } from './files'; + +function formatJob(job: MistralBatchJob) { + return { + id: job.id, + status: job.status, + endpoint: job.endpoint, + model: job.model ?? null, + agent_id: job.agent_id ?? null, + input_files: job.input_files ?? [], + output_file: job.output_file ?? null, + error_file: job.error_file ?? null, + total_requests: job.total_requests, + completed_requests: job.completed_requests, + succeeded_requests: job.succeeded_requests, + failed_requests: job.failed_requests, + errors: (job.errors ?? []).map((error) => ({ message: error.message, count: error.count ?? null })), + metadata: job.metadata ?? null, + outputs: job.outputs ?? null, + created_at: fileUtils.toIso(job.created_at), + started_at: fileUtils.toIso(job.started_at), + completed_at: fileUtils.toIso(job.completed_at), + }; +} + +function jobIdProp() { + return Property.ShortText({ + displayName: 'Batch Job ID', + description: 'The batch job UUID, from Create Batch Job or List Batch Jobs.', + required: true, + }); +} + +export const batchUtils = { formatJob, jobIdProp }; + +export const BATCH_STATUS_OPTIONS = [ + { label: 'Queued', value: 'QUEUED' }, + { label: 'Running', value: 'RUNNING' }, + { label: 'Success', value: 'SUCCESS' }, + { label: 'Failed', value: 'FAILED' }, + { label: 'Timeout Exceeded', value: 'TIMEOUT_EXCEEDED' }, + { label: 'Cancellation Requested', value: 'CANCELLATION_REQUESTED' }, + { label: 'Cancelled', value: 'CANCELLED' }, +]; + +export const BATCH_ENDPOINT_OPTIONS = [ + { label: 'Chat Completions', value: '/v1/chat/completions' }, + { label: 'Embeddings', value: '/v1/embeddings' }, + { label: 'FIM Completions', value: '/v1/fim/completions' }, + { label: 'Moderations', value: '/v1/moderations' }, + { label: 'Chat Moderations', value: '/v1/chat/moderations' }, + { label: 'OCR', value: '/v1/ocr' }, + { label: 'Classifications', value: '/v1/classifications' }, + { label: 'Chat Classifications', value: '/v1/chat/classifications' }, + { label: 'Conversations', value: '/v1/conversations' }, + { label: 'Audio Transcriptions', value: '/v1/audio/transcriptions' }, +]; + +export type MistralBatchJob = { + id: string; + status: string; + endpoint: string; + model?: string | null; + agent_id?: string | null; + input_files?: string[]; + output_file?: string | null; + error_file?: string | null; + errors?: { message: string; count?: number }[]; + outputs?: Record[] | null; + metadata?: Record | null; + total_requests: number; + completed_requests: number; + succeeded_requests: number; + failed_requests: number; + created_at: number; + started_at?: number | null; + completed_at?: number | null; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/client.ts b/packages/pieces/community/mistral-ai/src/lib/common/client.ts new file mode 100644 index 000000000000..9f70586375ea --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/client.ts @@ -0,0 +1,166 @@ +import { HttpError, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { isNil } from '@activepieces/pieces-framework'; +import { Stream } from 'stream'; +import { MistralAuthValue, mistralRequest } from './request'; + +async function call({ auth, method, path, body, queryParams, headers, timeout, responseType }: MistralCallParams): Promise { + const config = mistralRequest.getConfig(auth); + for (let attempt = 0; ; attempt++) { + try { + const response = await httpClient.sendRequest({ + method, + url: `${config.baseUrl}${path}`, + headers: { ...config.headers, ...headers }, + body, + queryParams: toQueryParams(queryParams), + timeout, + responseType, + }); + return response.body; + } catch (e) { + if (isRateLimited(e) && isRetrySafe({ method, path, body }) && attempt < MAX_RATE_LIMIT_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); + continue; + } + throw new Error(describeError({ error: e, isGateway: config.baseUrl.includes(GATEWAY_HOST) })); + } + } +} + +function parseJsonInput({ value, fieldName }: { value: unknown; fieldName: string }): unknown { + if (isNil(value) || value === '') { + return undefined; + } + if (typeof value !== 'string') { + return value; + } + try { + return JSON.parse(value); + } catch { + throw new Error(`${fieldName} must be valid JSON.`); + } +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => String(item).trim()).filter((item) => item.length > 0); +} + +function contentToText(content: unknown): string | null { + if (isNil(content)) { + return null; + } + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content.map((chunk) => (isRecord(chunk) && typeof chunk['text'] === 'string' ? chunk['text'] : '')).join(''); + } + return null; +} + +function compact(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => !isNil(value) && value !== '')); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const mistralApi = { call, parseJsonInput, toStringArray, contentToText, compact, isRecord }; + +function toQueryParams(params: MistralCallParams['queryParams']): Record | undefined { + if (isNil(params)) { + return undefined; + } + return Object.fromEntries( + Object.entries(params) + .filter(([, value]) => !isNil(value) && value !== '') + .map(([key, value]) => [key, String(value)]), + ); +} + +function isRateLimited(error: unknown): boolean { + return error instanceof HttpError && error.response.status === 429; +} + +function isRetrySafe({ method, path, body }: { method: HttpMethod; path: string; body: unknown }): boolean { + if (body instanceof Stream) { + return false; + } + return method !== HttpMethod.POST || STATELESS_POST_PATHS.includes(path); +} + +function describeError({ error, isGateway }: { error: unknown; isGateway: boolean }): string { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : String(error); + } + const status = error.response.status; + const detail = extractDetail(error.response.body); + const suffix = detail ? `: ${detail}` : ''; + switch (status) { + case 401: + return 'Mistral rejected the API key (401). Check the connection.'; + case 403: + return `Mistral denied access to this resource (403)${suffix}`; + case 404: + return isGateway + ? `Not found (404). The id may be wrong, or this endpoint is not passed through your Cloudflare AI Gateway${suffix}` + : `Not found (404). Check the id you passed${suffix}`; + case 422: + return `Mistral rejected the request as invalid (422)${suffix}`; + case 429: + return `Mistral rate limit or capacity limit reached (429). Wait and try again${suffix}`; + default: + return `Mistral request failed (${status})${suffix}`; + } +} + +function extractDetail(body: unknown): string | null { + if (isNil(body)) { + return null; + } + if (typeof body === 'string') { + return body.slice(0, 500); + } + if (!isRecord(body)) { + return String(body); + } + if (typeof body['message'] === 'string') { + return body['message']; + } + const detail = body['detail']; + if (typeof detail === 'string') { + return detail; + } + if (Array.isArray(detail)) { + return detail.map(formatValidationItem).join('; '); + } + return JSON.stringify(body).slice(0, 500); +} + +function formatValidationItem(item: unknown): string { + if (!isRecord(item)) { + return String(item); + } + const location = Array.isArray(item['loc']) ? item['loc'].join('.') : ''; + const message = typeof item['msg'] === 'string' ? item['msg'] : ''; + return `${location}: ${message}`; +} + +const GATEWAY_HOST = 'gateway.ai.cloudflare.com'; +const MAX_RATE_LIMIT_RETRIES = 3; +const STATELESS_POST_PATHS = ['/chat/completions', '/fim/completions', '/moderations', '/chat/moderations', '/audio/speech']; + +type MistralCallParams = { + auth: MistralAuthValue; + method: HttpMethod; + path: string; + body?: unknown; + queryParams?: Record; + headers?: Record; + timeout?: number; + responseType?: 'arraybuffer' | 'json'; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/conversations.ts b/packages/pieces/community/mistral-ai/src/lib/common/conversations.ts new file mode 100644 index 000000000000..4d2c2381213d --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/conversations.ts @@ -0,0 +1,88 @@ +import { Property } from '@activepieces/pieces-framework'; +import { mistralApi } from './client'; + +function formatEntry(entry: ConversationEntry) { + return { + id: entry.id ?? null, + type: entry.type ?? null, + role: entry.role ?? null, + content: mistralApi.contentToText(entry.content), + chunks: Array.isArray(entry.content) ? entry.content.filter((chunk) => !(mistralApi.isRecord(chunk) && chunk['type'] === 'text')) : [], + name: entry.name ?? null, + arguments: entry.arguments ?? null, + agent_id: entry.agent_id ?? null, + model: entry.model ?? null, + created_at: entry.created_at ?? null, + }; +} + +function formatResponse(response: ConversationResponse) { + const outputs = response.outputs.map(formatEntry); + const reply = outputs + .filter((output) => output.type === 'message.output') + .map((output) => output.content ?? '') + .join('\n') + .trim(); + return { + conversation_id: response.conversation_id, + reply: reply.length > 0 ? reply : null, + outputs, + prompt_tokens: response.usage?.prompt_tokens ?? null, + completion_tokens: response.usage?.completion_tokens ?? null, + total_tokens: response.usage?.total_tokens ?? null, + }; +} + +function formatConversation(conversation: MistralConversation) { + return { + id: conversation.id, + name: conversation.name ?? null, + description: conversation.description ?? null, + agent_id: conversation.agent_id ?? null, + agent_version: conversation.agent_version ?? null, + model: conversation.model ?? null, + instructions: conversation.instructions ?? null, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + }; +} + +function conversationIdProp() { + return Property.ShortText({ + displayName: 'Conversation ID', + description: 'The conversation id (usually starts with conv_), from Start Conversation or List Conversations.', + required: true, + }); +} + +export const conversationUtils = { formatEntry, formatResponse, formatConversation, conversationIdProp }; + +export type ConversationEntry = { + id?: string; + type?: string; + role?: string; + content?: unknown; + name?: string; + arguments?: string; + agent_id?: string | null; + model?: string | null; + created_at?: string; +}; + +export type ConversationResponse = { + conversation_id: string; + outputs: ConversationEntry[]; + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; +}; + +export type MistralConversation = { + id: string; + name?: string | null; + description?: string | null; + agent_id?: string; + agent_version?: number | string | null; + model?: string; + instructions?: string | null; + created_at: string; + updated_at: string; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/files.ts b/packages/pieces/community/mistral-ai/src/lib/common/files.ts new file mode 100644 index 000000000000..b03e42854912 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/files.ts @@ -0,0 +1,42 @@ +function formatFile(file: MistralFile) { + return { + id: file.id, + filename: file.filename, + purpose: file.purpose, + bytes: file.bytes, + mimetype: file.mimetype ?? null, + source: file.source ?? null, + sample_type: file.sample_type ?? null, + num_lines: file.num_lines ?? null, + created_at: toIso(file.created_at), + expires_at: toIso(file.expires_at), + }; +} + +function toIso(seconds: number | null | undefined): string | null { + if (seconds === null || seconds === undefined) { + return null; + } + return new Date(seconds * 1000).toISOString(); +} + +export const fileUtils = { formatFile, toIso }; + +export const FILE_PURPOSE_OPTIONS = [ + { label: 'OCR', value: 'ocr' }, + { label: 'Batch', value: 'batch' }, + { label: 'Fine-tune', value: 'fine-tune' }, +]; + +export type MistralFile = { + id: string; + filename: string; + purpose: string; + bytes: number; + created_at: number; + mimetype?: string | null; + source?: string; + sample_type?: string; + num_lines?: number | null; + expires_at?: number | null; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/libraries.ts b/packages/pieces/community/mistral-ai/src/lib/common/libraries.ts new file mode 100644 index 000000000000..6b605e9bd982 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/libraries.ts @@ -0,0 +1,87 @@ +import { Property } from '@activepieces/pieces-framework'; + +function formatLibrary(library: MistralLibrary) { + return { + id: library.id, + name: library.name, + description: library.description ?? null, + generated_description: library.generated_description ?? null, + owner_type: library.owner_type ?? null, + owner_id: library.owner_id ?? null, + nb_documents: library.nb_documents ?? null, + total_size: library.total_size ?? null, + chunk_size: library.chunk_size ?? null, + created_at: library.created_at, + updated_at: library.updated_at, + }; +} + +function formatDocument(document: MistralDocument) { + return { + id: document.id, + library_id: document.library_id, + name: document.name, + mime_type: document.mime_type ?? null, + extension: document.extension ?? null, + size: document.size ?? null, + number_of_pages: document.number_of_pages ?? null, + summary: document.summary ?? null, + process_status: document.process_status ?? null, + attributes: document.attributes ?? null, + expires_at: document.expires_at ?? null, + created_at: document.created_at, + last_processed_at: document.last_processed_at ?? null, + }; +} + +function libraryIdProp() { + return Property.ShortText({ + displayName: 'Library ID', + description: 'The library UUID, from List Libraries or Create Library.', + required: true, + }); +} + +function documentIdProp() { + return Property.ShortText({ + displayName: 'Document ID', + description: 'The document UUID, from List Library Documents or Upload Library Document.', + required: true, + }); +} + +function documentPath({ libraryId, documentId }: { libraryId: string; documentId: string }): string { + return `/libraries/${encodeURIComponent(libraryId)}/documents/${encodeURIComponent(documentId)}`; +} + +export const libraryUtils = { formatLibrary, formatDocument, libraryIdProp, documentIdProp, documentPath }; + +export type MistralLibrary = { + id: string; + name: string; + description?: string | null; + generated_description?: string | null; + owner_type?: string; + owner_id?: string | null; + nb_documents?: number; + total_size?: number; + chunk_size?: number | null; + created_at: string; + updated_at: string; +}; + +export type MistralDocument = { + id: string; + library_id: string; + name: string; + mime_type?: string | null; + extension?: string | null; + size?: number | null; + number_of_pages?: number | null; + summary?: string | null; + process_status?: string; + attributes?: Record | null; + expires_at?: string | null; + created_at: string; + last_processed_at?: string | null; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/moderation.ts b/packages/pieces/community/mistral-ai/src/lib/common/moderation.ts new file mode 100644 index 000000000000..60d9336d21c1 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/moderation.ts @@ -0,0 +1,28 @@ +function formatResponse({ response, inputs }: { response: ModerationResponse; inputs: string[] }) { + const results = response.results.map((result, index) => { + const flagged = Object.entries(result.categories ?? {}) + .filter(([, value]) => value === true) + .map(([category]) => category); + return { + input: inputs[index] ?? null, + flagged: flagged.length > 0, + flagged_categories: flagged, + categories: result.categories ?? {}, + category_scores: result.category_scores ?? {}, + }; + }); + return { + id: response.id, + model: response.model, + any_flagged: results.some((result) => result.flagged), + results, + }; +} + +export const moderationUtils = { formatResponse }; + +export type ModerationResponse = { + id: string; + model: string; + results: { categories?: Record; category_scores?: Record }[]; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/output-schemas.ts b/packages/pieces/community/mistral-ai/src/lib/output-schemas.ts new file mode 100644 index 000000000000..6dc6d3e5f3b2 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/output-schemas.ts @@ -0,0 +1,406 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const tokenUsageFields: OutputSchema['fields'] = [ + { key: 'prompt_tokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'completion_tokens', label: 'Completion Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, +]; + +const moderationFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Moderation ID' }, + { key: 'model', label: 'Model' }, + { key: 'any_flagged', label: 'Any Flagged', format: 'boolean' }, + { + key: 'results', + label: 'Results', + labelKey: 'input', + listItems: [ + { key: 'input', label: 'Input' }, + { key: 'flagged', label: 'Flagged', format: 'boolean' }, + { key: 'flagged_categories', label: 'Flagged Categories' }, + { key: 'categories', label: 'Categories', dynamicKey: true }, + { key: 'category_scores', label: 'Category Scores', dynamicKey: true }, + ], + }, +]; + +const modelFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Model ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'type', label: 'Type' }, + { key: 'owned_by', label: 'Owned By' }, + { key: 'max_context_length', label: 'Max Context Length', format: 'number' }, + { key: 'aliases', label: 'Aliases' }, + { key: 'deprecation', label: 'Deprecation Date', format: 'datetime' }, + { key: 'default_model_temperature', label: 'Default Temperature', format: 'number' }, + { key: 'capabilities', label: 'Capabilities', dynamicKey: true }, +]; + +const fileFields: OutputSchema['fields'] = [ + { key: 'id', label: 'File ID' }, + { key: 'filename', label: 'File Name' }, + { key: 'purpose', label: 'Purpose' }, + { key: 'bytes', label: 'Size', format: 'filesize' }, + { key: 'mimetype', label: 'MIME Type' }, + { key: 'source', label: 'Source' }, + { key: 'sample_type', label: 'Sample Type' }, + { key: 'num_lines', label: 'Number of Lines', format: 'number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'expires_at', label: 'Expires At', format: 'datetime' }, +]; + +const agentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Agent ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'model', label: 'Model' }, + { key: 'instructions', label: 'Instructions' }, + { key: 'tools', label: 'Tools' }, + { key: 'library_ids', label: 'Library IDs' }, + { key: 'temperature', label: 'Temperature', format: 'number' }, + { key: 'version', label: 'Version', format: 'number' }, + { key: 'source', label: 'Source' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const conversationEntryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Entry ID' }, + { key: 'type', label: 'Type' }, + { key: 'role', label: 'Role' }, + { key: 'content', label: 'Content' }, + { key: 'chunks', label: 'Files & References' }, + { key: 'name', label: 'Tool Name' }, + { key: 'arguments', label: 'Tool Arguments' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'model', label: 'Model' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const conversationReplyFields: OutputSchema['fields'] = [ + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'reply', label: 'Reply' }, + { key: 'outputs', label: 'Outputs', labelKey: 'type', listItems: conversationEntryFields }, + ...tokenUsageFields, +]; + +const conversationFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Conversation ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'agent_version', label: 'Agent Version' }, + { key: 'model', label: 'Model' }, + { key: 'instructions', label: 'Instructions' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const libraryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Library ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'generated_description', label: 'Generated Description' }, + { key: 'owner_type', label: 'Owner Type' }, + { key: 'owner_id', label: 'Owner ID' }, + { key: 'nb_documents', label: 'Number of Documents', format: 'number' }, + { key: 'total_size', label: 'Total Size', format: 'filesize' }, + { key: 'chunk_size', label: 'Chunk Size', format: 'number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const documentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Document ID' }, + { key: 'library_id', label: 'Library ID' }, + { key: 'name', label: 'Name' }, + { key: 'mime_type', label: 'MIME Type' }, + { key: 'extension', label: 'Extension' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'number_of_pages', label: 'Number of Pages', format: 'number' }, + { key: 'summary', label: 'Summary' }, + { key: 'process_status', label: 'Processing Status' }, + { key: 'attributes', label: 'Attributes', dynamicKey: true }, + { key: 'expires_at', label: 'Delete After', format: 'datetime' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'last_processed_at', label: 'Last Processed At', format: 'datetime' }, +]; + +const generatedFileFields: OutputSchema['fields'] = [ + { key: 'file', label: 'File', format: 'url' }, + { key: 'file_name', label: 'File Name' }, + { key: 'size_bytes', label: 'Size', format: 'filesize' }, +]; + +const batchJobFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Batch Job ID' }, + { key: 'status', label: 'Status' }, + { key: 'endpoint', label: 'Endpoint' }, + { key: 'model', label: 'Model' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'input_files', label: 'Input File IDs' }, + { key: 'output_file', label: 'Output File ID' }, + { key: 'error_file', label: 'Error File ID' }, + { key: 'total_requests', label: 'Total Requests', format: 'number' }, + { key: 'completed_requests', label: 'Completed Requests', format: 'number' }, + { key: 'succeeded_requests', label: 'Succeeded Requests', format: 'number' }, + { key: 'failed_requests', label: 'Failed Requests', format: 'number' }, + { + key: 'errors', + label: 'Errors', + labelKey: 'message', + listItems: [ + { key: 'message', label: 'Message' }, + { key: 'count', label: 'Count', format: 'number' }, + ], + }, + { key: 'metadata', label: 'Metadata', dynamicKey: true }, + { key: 'outputs', label: 'Inline Outputs' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'started_at', label: 'Started At', format: 'datetime' }, + { key: 'completed_at', label: 'Completed At', format: 'datetime' }, +]; + +export const generateChatCompletionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Completion ID' }, + { key: 'model', label: 'Model' }, + { key: 'content', label: 'Reply' }, + { key: 'finish_reason', label: 'Finish Reason' }, + ...tokenUsageFields, + ], +}; + +export const batchJobOutputSchema: OutputSchema = { fields: batchJobFields }; + +export const listBatchJobsOutputSchema: OutputSchema = { + fields: [ + { key: 'jobs', label: 'Batch Jobs', labelKey: 'id', listItems: batchJobFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + ], +}; + +export const completeCodeFimOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Completion ID' }, + { key: 'model', label: 'Model' }, + { key: 'completion', label: 'Completion' }, + { key: 'finish_reason', label: 'Finish Reason' }, + ...tokenUsageFields, + ], +}; + +export const transcribeAudioOutputSchema: OutputSchema = { + fields: [ + { key: 'text', label: 'Transcript' }, + { key: 'language', label: 'Language' }, + { key: 'model', label: 'Model' }, + { + key: 'segments', + label: 'Segments', + labelKey: 'text', + listItems: [ + { key: 'text', label: 'Text' }, + { key: 'start', label: 'Start (s)', format: 'number' }, + { key: 'end', label: 'End (s)', format: 'number' }, + { key: 'speaker_id', label: 'Speaker' }, + ], + }, + { key: 'prompt_audio_seconds', label: 'Audio Seconds', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, + ], +}; + +export const generateSpeechOutputSchema: OutputSchema = { + fields: [...generatedFileFields, { key: 'format', label: 'Audio Format' }], +}; + +export const moderateTextOutputSchema: OutputSchema = { fields: moderationFields }; + +export const moderateChatOutputSchema: OutputSchema = { fields: moderationFields }; + +export const getModelOutputSchema: OutputSchema = { fields: modelFields }; + +export const listModelsOutputSchema: OutputSchema = { + fields: [{ key: 'data', label: 'Models', labelKey: 'id', listItems: modelFields }], +}; + +export const createEmbeddingsOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Request ID' }, + { key: 'model', label: 'Model' }, + { + key: 'data', + label: 'Embeddings', + labelKey: 'index', + listItems: [ + { key: 'index', label: 'Index', format: 'number' }, + { key: 'embedding', label: 'Vector' }, + ], + }, + { + key: 'usage', + label: 'Usage', + children: [ + { key: 'prompt_tokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, + ], + }, + ], +}; + +export const uploadFileOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'File ID' }, + { key: 'filename', label: 'File Name' }, + { key: 'purpose', label: 'Purpose' }, + { key: 'bytes', label: 'Size', format: 'filesize' }, + { key: 'mimetype', label: 'MIME Type' }, + { key: 'source', label: 'Source' }, + { key: 'created_at', label: 'Created At (Unix seconds)', format: 'number' }, + ], +}; + +export const fileOutputSchema: OutputSchema = { fields: fileFields }; + +export const listFilesOutputSchema: OutputSchema = { + fields: [ + { key: 'files', label: 'Files', labelKey: 'filename', listItems: fileFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + ], +}; + +export const deleteFileOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'File ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const getFileSignedUrlOutputSchema: OutputSchema = { + fields: [ + { key: 'file_id', label: 'File ID' }, + { key: 'url', label: 'Download URL', format: 'url' }, + { key: 'expires_at', label: 'Expires At', format: 'datetime' }, + ], +}; + +export const downloadFileOutputSchema: OutputSchema = { + fields: [ + ...generatedFileFields, + { key: 'file_id', label: 'File ID' }, + { key: 'mimetype', label: 'MIME Type' }, + ], +}; + +export const agentOutputSchema: OutputSchema = { fields: agentFields }; + +export const listAgentsOutputSchema: OutputSchema = { + fields: [ + { key: 'agents', label: 'Agents', labelKey: 'name', listItems: agentFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; + +export const deleteAgentOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const conversationReplyOutputSchema: OutputSchema = { fields: conversationReplyFields }; + +export const conversationOutputSchema: OutputSchema = { fields: conversationFields }; + +export const listConversationsOutputSchema: OutputSchema = { + fields: [ + { key: 'conversations', label: 'Conversations', labelKey: 'name', listItems: conversationFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const conversationHistoryOutputSchema: OutputSchema = { + fields: [ + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'entries', label: 'Entries', labelKey: 'type', listItems: conversationEntryFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const conversationMessagesOutputSchema: OutputSchema = { + fields: [ + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'messages', label: 'Messages', labelKey: 'role', listItems: conversationEntryFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const deleteConversationOutputSchema: OutputSchema = { + fields: [ + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const libraryOutputSchema: OutputSchema = { fields: libraryFields }; + +export const listLibrariesOutputSchema: OutputSchema = { + fields: [ + { key: 'libraries', label: 'Libraries', labelKey: 'name', listItems: libraryFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; + +export const deleteLibraryOutputSchema: OutputSchema = { + fields: [...libraryFields, { key: 'deleted', label: 'Deleted', format: 'boolean' }], +}; + +export const documentOutputSchema: OutputSchema = { fields: documentFields }; + +export const listLibraryDocumentsOutputSchema: OutputSchema = { + fields: [ + { key: 'documents', label: 'Documents', labelKey: 'name', listItems: documentFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + ], +}; + +export const documentStatusOutputSchema: OutputSchema = { + fields: [ + { key: 'document_id', label: 'Document ID' }, + { key: 'process_status', label: 'Processing Status' }, + { key: 'is_done', label: 'Done', format: 'boolean' }, + { key: 'is_error', label: 'Error', format: 'boolean' }, + ], +}; + +export const documentTextContentOutputSchema: OutputSchema = { + fields: [ + { key: 'document_id', label: 'Document ID' }, + { key: 'text', label: 'Text' }, + { key: 'length', label: 'Length', format: 'number' }, + ], +}; + +export const deleteLibraryDocumentOutputSchema: OutputSchema = { + fields: [ + { key: 'library_id', label: 'Library ID' }, + { key: 'document_id', label: 'Document ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const reprocessDocumentOutputSchema: OutputSchema = { + fields: [ + { key: 'library_id', label: 'Library ID' }, + { key: 'document_id', label: 'Document ID' }, + { key: 'reprocessing', label: 'Reprocessing', format: 'boolean' }, + ], +}; diff --git a/packages/pieces/community/salesforce/package.json b/packages/pieces/community/salesforce/package.json index 322b5ba04e84..b536707bf1af 100644 --- a/packages/pieces/community/salesforce/package.json +++ b/packages/pieces/community/salesforce/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-salesforce", - "version": "0.8.1", + "version": "0.9.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/salesforce/src/index.ts b/packages/pieces/community/salesforce/src/index.ts index 10cf4ec8ad41..a0e11ea37cce 100644 --- a/packages/pieces/community/salesforce/src/index.ts +++ b/packages/pieces/community/salesforce/src/index.ts @@ -43,6 +43,83 @@ import { newRecord } from './lib/trigger/new-record'; import { newUpdatedFile } from './lib/trigger/new-updated-file'; import { exportReport } from './lib/action/export-report'; import { newCaseCreatedTrigger } from './lib/trigger/new-case-in-queue'; +import { addContactToCampaignMember } from './lib/action/ai/add-contact-to-campaign-member'; +import { addLeadToCampaignMember } from './lib/action/ai/add-lead-to-campaign-member'; +import { addOpportunityProduct } from './lib/action/ai/add-opportunity-product'; +import { applyLeadAssignmentRules } from './lib/action/ai/apply-lead-assignment-rules'; +import { completeTask } from './lib/action/ai/complete-task'; +import { createAccount } from './lib/action/ai/create-account'; +import { createBulkQueryJob } from './lib/action/ai/create-bulk-query-job'; +import { createCampaign } from './lib/action/ai/create-campaign'; +import { createRecordTree } from './lib/action/ai/create-record-tree'; +import { createRecordsBatch } from './lib/action/ai/create-records-batch'; +import { createSfContact } from './lib/action/ai/create-sf-contact'; +import { createSfLead } from './lib/action/ai/create-sf-lead'; +import { createSfNote } from './lib/action/ai/create-sf-note'; +import { createSfOpportunity } from './lib/action/ai/create-sf-opportunity'; +import { createSfTask } from './lib/action/ai/create-sf-task'; +import { createSobjectRecord } from './lib/action/ai/create-sobject-record'; +import { deleteFile } from './lib/action/ai/delete-file'; +import { deleteRecordsBatch } from './lib/action/ai/delete-records-batch'; +import { deleteSobjectRecord } from './lib/action/ai/delete-sobject-record'; +import { describeObject } from './lib/action/ai/describe-object'; +import { downloadFile } from './lib/action/ai/download-file'; +import { getBulkIngestJob } from './lib/action/ai/get-bulk-ingest-job'; +import { getBulkIngestResults } from './lib/action/ai/get-bulk-ingest-results'; +import { getBulkQueryJob } from './lib/action/ai/get-bulk-query-job'; +import { getBulkQueryResults } from './lib/action/ai/get-bulk-query-results'; +import { getChildRecords } from './lib/action/ai/get-child-records'; +import { getCurrentUser } from './lib/action/ai/get-current-user'; +import { getDashboard } from './lib/action/ai/get-dashboard'; +import { getFileInfo } from './lib/action/ai/get-file-info'; +import { getListViewMetadata } from './lib/action/ai/get-list-view-metadata'; +import { getListViewRecords } from './lib/action/ai/get-list-view-records'; +import { getNextQueryPage } from './lib/action/ai/get-next-query-page'; +import { getOrgLimits } from './lib/action/ai/get-org-limits'; +import { getQuickActionDefaults } from './lib/action/ai/get-quick-action-defaults'; +import { getRecordByExternalId } from './lib/action/ai/get-record-by-external-id'; +import { getRecordCounts } from './lib/action/ai/get-record-counts'; +import { getRecord } from './lib/action/ai/get-record'; +import { getRecordsBatch } from './lib/action/ai/get-records-batch'; +import { getReportInstance } from './lib/action/ai/get-report-instance'; +import { getReport } from './lib/action/ai/get-report'; +import { getUpdatedRecordIds } from './lib/action/ai/get-updated-record-ids'; +import { listDashboards } from './lib/action/ai/list-dashboards'; +import { listEmailTemplates } from './lib/action/ai/list-email-templates'; +import { listInvocableActions } from './lib/action/ai/list-invocable-actions'; +import { listListViews } from './lib/action/ai/list-list-views'; +import { listObjects } from './lib/action/ai/list-objects'; +import { listPricebooks } from './lib/action/ai/list-pricebooks'; +import { listQuickActions } from './lib/action/ai/list-quick-actions'; +import { listReports } from './lib/action/ai/list-reports'; +import { logCall } from './lib/action/ai/log-call'; +import { logEmailActivity } from './lib/action/ai/log-email-activity'; +import { removeCampaignMember } from './lib/action/ai/remove-campaign-member'; +import { runBulkIngestJob } from './lib/action/ai/run-bulk-ingest-job'; +import { runInvocableAction } from './lib/action/ai/run-invocable-action'; +import { runQuickAction } from './lib/action/ai/run-quick-action'; +import { runReportAsync } from './lib/action/ai/run-report-async'; +import { runReportSync } from './lib/action/ai/run-report-sync'; +import { runSoqlQueryAll } from './lib/action/ai/run-soql-query-all'; +import { runSoqlQuery } from './lib/action/ai/run-soql-query'; +import { runToolingQuery } from './lib/action/ai/run-tooling-query'; +import { searchAccounts } from './lib/action/ai/search-accounts'; +import { searchContacts } from './lib/action/ai/search-contacts'; +import { searchKnowledgeArticles } from './lib/action/ai/search-knowledge-articles'; +import { searchLeads } from './lib/action/ai/search-leads'; +import { searchOpportunities } from './lib/action/ai/search-opportunities'; +import { searchRecordsSosl } from './lib/action/ai/search-records-sosl'; +import { sendEmailMessage } from './lib/action/ai/send-email-message'; +import { updateAccount } from './lib/action/ai/update-account'; +import { updateCampaign } from './lib/action/ai/update-campaign'; +import { updateOpportunity } from './lib/action/ai/update-opportunity'; +import { updateSfContact } from './lib/action/ai/update-sf-contact'; +import { updateSfLead } from './lib/action/ai/update-sf-lead'; +import { updateSobjectRecord } from './lib/action/ai/update-sobject-record'; +import { updateTask } from './lib/action/ai/update-task'; +import { uploadFile } from './lib/action/ai/upload-file'; +import { upsertRecordByExternalId } from './lib/action/ai/upsert-record-by-external-id'; +import { upsertRecordsBatch } from './lib/action/ai/upsert-records-batch'; export const salesforceAuth = PieceAuth.OAuth2({ props: { @@ -66,7 +143,7 @@ export const salesforceAuth = PieceAuth.OAuth2({ export const salesforce = createPiece({ displayName: 'Salesforce', description: 'CRM software solutions and enterprise cloud computing', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/salesforce.png', authors: [ 'HKudria', @@ -107,6 +184,83 @@ export const salesforce = createPiece({ updateRecord, upsertByExternalId, upsertByExternalIdBulk, + addContactToCampaignMember, + addLeadToCampaignMember, + addOpportunityProduct, + applyLeadAssignmentRules, + completeTask, + createAccount, + createBulkQueryJob, + createCampaign, + createRecordTree, + createRecordsBatch, + createSfContact, + createSfLead, + createSfNote, + createSfOpportunity, + createSfTask, + createSobjectRecord, + deleteFile, + deleteRecordsBatch, + deleteSobjectRecord, + describeObject, + downloadFile, + getBulkIngestJob, + getBulkIngestResults, + getBulkQueryJob, + getBulkQueryResults, + getChildRecords, + getCurrentUser, + getDashboard, + getFileInfo, + getListViewMetadata, + getListViewRecords, + getNextQueryPage, + getOrgLimits, + getQuickActionDefaults, + getRecordByExternalId, + getRecordCounts, + getRecord, + getRecordsBatch, + getReportInstance, + getReport, + getUpdatedRecordIds, + listDashboards, + listEmailTemplates, + listInvocableActions, + listListViews, + listObjects, + listPricebooks, + listQuickActions, + listReports, + logCall, + logEmailActivity, + removeCampaignMember, + runBulkIngestJob, + runInvocableAction, + runQuickAction, + runReportAsync, + runReportSync, + runSoqlQueryAll, + runSoqlQuery, + runToolingQuery, + searchAccounts, + searchContacts, + searchKnowledgeArticles, + searchLeads, + searchOpportunities, + searchRecordsSosl, + sendEmailMessage, + updateAccount, + updateCampaign, + updateOpportunity, + updateSfContact, + updateSfLead, + updateSobjectRecord, + updateTask, + uploadFile, + upsertRecordByExternalId, + upsertRecordsBatch, createCustomApiCallAction({ baseUrl: (auth) => (auth as OAuth2PropertyValue).data['instance_url'], auth: salesforceAuth, diff --git a/packages/pieces/community/salesforce/src/lib/action/add-contact-to-campaign.ts b/packages/pieces/community/salesforce/src/lib/action/add-contact-to-campaign.ts index 604eca13f6ce..7420086b7d99 100644 --- a/packages/pieces/community/salesforce/src/lib/action/add-contact-to-campaign.ts +++ b/packages/pieces/community/salesforce/src/lib/action/add-contact-to-campaign.ts @@ -2,14 +2,16 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const addContactToCampaign = createAction({ auth: salesforceAuth, name: 'add_contact_to_campaign', displayName: 'Add Contact to Campaign', description: 'Add a contact to a campaign.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Add a Contact to a Campaign by creating a CampaignMember linking the two, with an optional member status. Not idempotent — re-adding the same contact to the same campaign errors on the existing membership.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { campaign_id: salesforcesCommon.campaign, contact_id: salesforcesCommon.contact, diff --git a/packages/pieces/community/salesforce/src/lib/action/add-file-to-record.ts b/packages/pieces/community/salesforce/src/lib/action/add-file-to-record.ts index 8c6319b8b501..994155510ff3 100644 --- a/packages/pieces/community/salesforce/src/lib/action/add-file-to-record.ts +++ b/packages/pieces/community/salesforce/src/lib/action/add-file-to-record.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const addFileToRecord = createAction({ auth: salesforceAuth, name: 'add_file_to_record', displayName: 'Add File to Record', description: 'Uploads a file and attaches it to an existing record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Upload a file and attach it to an existing Salesforce record by creating a ContentVersion linked to the target record Id. Use when you need to attach a document, image, or other binary to a record. Not idempotent: each call creates a new file version, so re-running attaches a duplicate file.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { object: salesforcesCommon.object, record_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/add-lead-to-campaign.ts b/packages/pieces/community/salesforce/src/lib/action/add-lead-to-campaign.ts index 0fdabb9e7b73..b2ce1672549d 100644 --- a/packages/pieces/community/salesforce/src/lib/action/add-lead-to-campaign.ts +++ b/packages/pieces/community/salesforce/src/lib/action/add-lead-to-campaign.ts @@ -2,14 +2,16 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const addLeadToCampaign = createAction({ auth: salesforceAuth, name: 'add_lead_to_campaign', displayName: 'Add Lead to Campaign', description: 'Adds an existing lead to an existing campaign.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Link an existing Lead to an existing Campaign by creating a CampaignMember with the given member status. Use to enroll a known lead in a campaign. Not idempotent: re-running creates another CampaignMember, and Salesforce rejects a duplicate lead/campaign pairing with an error.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { campaign_id: salesforcesCommon.campaign, lead_id: salesforcesCommon.lead, diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/add-contact-to-campaign-member.ts b/packages/pieces/community/salesforce/src/lib/action/ai/add-contact-to-campaign-member.ts new file mode 100644 index 000000000000..0626ca665965 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/add-contact-to-campaign-member.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { addContactToCampaignMemberOutputSchema } from '../../output-schemas'; + +export const addContactToCampaignMember = createAction({ + auth: salesforceAuth, + name: 'add_contact_to_campaign_member', + classification: 'WRITE', + displayName: 'Add Contact to Campaign', + description: 'Add an existing contact to a campaign as a member.', + audience: 'ai', + aiMetadata: { + description: + 'Enrolls an existing Contact in a Campaign by creating a CampaignMember, with an optional member status (e.g. Sent or Responded; defaults to the campaign default). Use Add Lead to Campaign for leads and Remove Campaign Member to take it out. Not idempotent: Salesforce rejects the call if the contact is already a member of that campaign.', + idempotent: false, + }, + outputSchema: addContactToCampaignMemberOutputSchema, + props: { + campaign_id: Property.ShortText({ displayName: 'Campaign ID', required: true }), + contact_id: Property.ShortText({ displayName: 'Contact ID', required: true }), + status: Property.ShortText({ displayName: 'Member Status', description: 'Campaign member status, e.g. Sent. Leave empty for the default.', required: false }), + }, + async run(context) { + const { campaign_id, contact_id, status } = context.propsValue; + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'CampaignMember', + fields: { CampaignId: campaign_id, ContactId: contact_id, Status: status }, + additionalFields: undefined, + }); + return { ...result, campaign_id, contact_id }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/add-lead-to-campaign-member.ts b/packages/pieces/community/salesforce/src/lib/action/ai/add-lead-to-campaign-member.ts new file mode 100644 index 000000000000..0a89b617b3c4 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/add-lead-to-campaign-member.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { addLeadToCampaignMemberOutputSchema } from '../../output-schemas'; + +export const addLeadToCampaignMember = createAction({ + auth: salesforceAuth, + name: 'add_lead_to_campaign_member', + classification: 'WRITE', + displayName: 'Add Lead to Campaign', + description: 'Add an existing lead to a campaign as a member.', + audience: 'ai', + aiMetadata: { + description: + 'Enrolls an existing Lead in a Campaign by creating a CampaignMember, with an optional member status (e.g. Sent or Responded; defaults to the campaign default). Use Add Contact to Campaign for contacts and Remove Campaign Member to take it out. Not idempotent: Salesforce rejects the call if the lead is already a member of that campaign.', + idempotent: false, + }, + outputSchema: addLeadToCampaignMemberOutputSchema, + props: { + campaign_id: Property.ShortText({ displayName: 'Campaign ID', required: true }), + lead_id: Property.ShortText({ displayName: 'Lead ID', required: true }), + status: Property.ShortText({ displayName: 'Member Status', description: 'Campaign member status, e.g. Sent. Leave empty for the default.', required: false }), + }, + async run(context) { + const { campaign_id, lead_id, status } = context.propsValue; + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'CampaignMember', + fields: { CampaignId: campaign_id, LeadId: lead_id, Status: status }, + additionalFields: undefined, + }); + return { ...result, campaign_id, lead_id }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/add-opportunity-product.ts b/packages/pieces/community/salesforce/src/lib/action/ai/add-opportunity-product.ts new file mode 100644 index 000000000000..fd6be517bf02 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/add-opportunity-product.ts @@ -0,0 +1,47 @@ +import { createAction, isNil, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { addOpportunityProductOutputSchema } from '../../output-schemas'; + +export const addOpportunityProduct = createAction({ + auth: salesforceAuth, + name: 'add_opportunity_product', + classification: 'WRITE', + displayName: 'Add Opportunity Product', + description: 'Add a product line item to an opportunity.', + audience: 'ai', + aiMetadata: { + description: + "Adds a product line item (OpportunityLineItem) to an Opportunity from a price book entry, with a quantity and exactly one of unit price or total price. Get the entry id from List Price Books with entries included; the entry must belong to the opportunity's price book (set it with Update Opportunity first if empty). Not idempotent: each call adds another line.", + idempotent: false, + }, + outputSchema: addOpportunityProductOutputSchema, + props: { + opportunity_id: Property.ShortText({ displayName: 'Opportunity ID', required: true }), + pricebook_entry_id: Property.ShortText({ displayName: 'Price Book Entry ID', required: true }), + quantity: Property.Number({ displayName: 'Quantity', required: true }), + unit_price: Property.Number({ displayName: 'Unit Price', description: 'Sales price per unit. Do not combine with Total Price.', required: false }), + total_price: Property.Number({ displayName: 'Total Price', description: 'Quantity times price. Do not combine with Unit Price.', required: false }), + description: Property.LongText({ displayName: 'Line Description', required: false }), + }, + async run(context) { + const { opportunity_id, pricebook_entry_id, quantity, unit_price, total_price, description } = context.propsValue; + if (isNil(unit_price) === isNil(total_price)) { + throw new Error('Provide exactly one of Unit Price or Total Price.'); + } + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'OpportunityLineItem', + fields: { + OpportunityId: opportunity_id, + PricebookEntryId: pricebook_entry_id, + Quantity: quantity, + UnitPrice: unit_price, + TotalPrice: total_price, + Description: description, + }, + additionalFields: undefined, + }); + return { ...result, opportunity_id, pricebook_entry_id, quantity }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/apply-lead-assignment-rules.ts b/packages/pieces/community/salesforce/src/lib/action/ai/apply-lead-assignment-rules.ts new file mode 100644 index 000000000000..98db6f01c78c --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/apply-lead-assignment-rules.ts @@ -0,0 +1,57 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { applyLeadAssignmentRulesOutputSchema } from '../../output-schemas'; + +export const applyLeadAssignmentRules = createAction({ + auth: salesforceAuth, + name: 'apply_lead_assignment_rules', + classification: 'WRITE', + displayName: 'Apply Lead Assignment Rules', + description: "Re-run the org's active lead assignment rule on a lead.", + audience: 'ai', + aiMetadata: { + description: + "Runs the org's active lead assignment rule on an existing Lead so it gets routed to the right owner or queue, and reports the owner before and after. Use it after Create Lead or Update Lead when ownership should follow the org's routing; to set an owner directly use Update Lead with Owner ID. Needs an active lead assignment rule in the org, otherwise the owner does not change. Safe to retry.", + idempotent: true, + }, + outputSchema: applyLeadAssignmentRulesOutputSchema, + props: { + lead_id: Property.ShortText({ displayName: 'Lead ID', required: true }), + }, + async run(context) { + const id = salesforceUtils.assertId({ value: context.propsValue.lead_id, fieldName: 'Lead ID' }); + const before = await getLead({ auth: context.auth, id }); + await callSalesforceApi( + HttpMethod.PATCH, + context.auth, + `/services/data/v56.0/sobjects/Lead/${id}`, + { Status: before.Status }, + { headers: { 'Sforce-Auto-Assign': 'TRUE' } } + ); + const after = await getLead({ auth: context.auth, id }); + return { + id, + previous_owner_id: before.OwnerId, + owner_id: after.OwnerId, + reassigned: before.OwnerId !== after.OwnerId, + }; + }, +}); + +async function getLead({ auth, id }: { auth: OAuth2PropertyValue; id: string }) { + const response = await callSalesforceApi( + HttpMethod.GET, + auth, + `/services/data/v56.0/sobjects/Lead/${id}?fields=Status,OwnerId`, + undefined + ); + return response.body; +} + +type LeadOwnerRecord = { + Status: string; + OwnerId: string; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/complete-task.ts b/packages/pieces/community/salesforce/src/lib/action/ai/complete-task.ts new file mode 100644 index 000000000000..aea2931cf676 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/complete-task.ts @@ -0,0 +1,50 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { crmUtils } from '../../common/crm'; +import { salesforceUtils } from '../../common/utils'; +import { completeTaskOutputSchema } from '../../output-schemas'; + +export const completeTask = createAction({ + auth: salesforceAuth, + name: 'complete_task', + classification: 'WRITE', + displayName: 'Complete Task', + description: 'Mark a task as completed.', + audience: 'ai', + aiMetadata: { + description: + "Marks an existing Salesforce Task as done by setting its status to the org's first closed task status (usually Completed), optionally appending a closing note to its description. Use Update Task for other changes. Safe to retry, but a closing note is appended again on every call.", + idempotent: true, + }, + outputSchema: completeTaskOutputSchema, + props: { + task_id: Property.ShortText({ displayName: 'Task ID', required: true }), + closing_note: Property.LongText({ displayName: 'Closing Note', description: 'Text appended to the task description.', required: false }), + }, + async run(context) { + const id = salesforceUtils.assertId({ value: context.propsValue.task_id, fieldName: 'Task ID' }); + const note = context.propsValue.closing_note?.trim(); + const status = await crmUtils.getClosedTaskStatus({ auth: context.auth }); + const description = note ? await appendToDescription({ auth: context.auth, id, note }) : undefined; + const body = salesforceUtils.compact({ Status: status, Description: description }); + await callSalesforceApi(HttpMethod.PATCH, context.auth, `/services/data/v56.0/sobjects/Task/${id}`, body); + return { id, success: true, status, updated_fields: Object.keys(body) }; + }, +}); + +async function appendToDescription({ auth, id, note }: { auth: OAuth2PropertyValue; id: string; note: string }): Promise { + const response = await callSalesforceApi<{ Description: string | null }>( + HttpMethod.GET, + auth, + `/services/data/v56.0/sobjects/Task/${id}?fields=Description`, + undefined + ); + const current = response.body.Description; + if (!current) { + return note; + } + const appended = `${current}\n\n${note}`; + return current === note || current.endsWith(`\n\n${note}`) ? current : appended; +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-account.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-account.ts new file mode 100644 index 000000000000..6c1ac3fef876 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-account.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createAccountOutputSchema } from '../../output-schemas'; + +export const createAccount = createAction({ + auth: salesforceAuth, + name: 'create_account', + classification: 'WRITE', + displayName: 'Create Account', + description: 'Create a new account.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Account (company) with a required Name and optional phone, website, industry, billing address, owner and parent account; put custom fields in Additional Fields. Use Search Accounts first to avoid duplicates, and Update Account to change an existing one. Not idempotent: each call creates a new Account.', + idempotent: false, + }, + outputSchema: createAccountOutputSchema, + props: { + Name: Property.ShortText({ displayName: 'Account Name', required: true }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + Website: Property.ShortText({ displayName: 'Website', required: false }), + Industry: Property.ShortText({ displayName: 'Industry', description: 'Industry picklist value, e.g. Technology.', required: false }), + Type: Property.ShortText({ displayName: 'Type', description: 'Account type picklist value, e.g. Customer - Direct or Prospect.', required: false }), + BillingStreet: Property.ShortText({ displayName: 'Billing Street', required: false }), + BillingCity: Property.ShortText({ displayName: 'Billing City', required: false }), + BillingState: Property.ShortText({ displayName: 'Billing State', required: false }), + BillingPostalCode: Property.ShortText({ displayName: 'Billing Postal Code', required: false }), + BillingCountry: Property.ShortText({ displayName: 'Billing Country', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', description: 'User id of the account owner.', required: false }), + ParentId: Property.ShortText({ displayName: 'Parent Account ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const result = await crmUtils.createRecord({ auth: context.auth, object: 'Account', fields, additionalFields: additional_fields }); + return { ...result, name: fields.Name }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-bulk-query-job.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-bulk-query-job.ts new file mode 100644 index 000000000000..b48e23bb45d0 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-bulk-query-job.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { bulkUtils } from '../../common/bulk'; +import { bulkJobCreatedOutputSchema } from '../../output-schemas'; + +export const createBulkQueryJob = createAction({ + auth: salesforceAuth, + name: 'create_bulk_query_job', + classification: 'READ', + displayName: 'Create Bulk Query Job', + description: 'Start a Bulk API 2.0 query job for large SOQL exports.', + audience: 'ai', + aiMetadata: { + description: + 'Starts an asynchronous Bulk API 2.0 SOQL query and returns the job id immediately. Poll Get Bulk Query Job until JobComplete, then page through rows with Get Bulk Query Results. Use it for exports of many thousands of rows; for small result sets use the regular SOQL query action. queryAll also returns deleted and archived records. Starts a new job every call.', + idempotent: false, + }, + outputSchema: bulkJobCreatedOutputSchema, + props: { + query: Property.LongText({ + displayName: 'SOQL Query', + description: 'e.g. SELECT Id, Name FROM Account. Bulk queries do not support GROUP BY, OFFSET or TYPEOF.', + required: true, + }), + operation: Property.StaticDropdown({ + displayName: 'Operation', + required: true, + defaultValue: 'query', + options: { + options: [ + { label: 'Query', value: 'query' }, + { label: 'Query All (include deleted)', value: 'queryAll' }, + ], + }, + }), + }, + async run(context) { + const query = context.propsValue.query.trim(); + if (!query) { + throw new Error('SOQL Query must not be empty.'); + } + const response = await callSalesforceApi(HttpMethod.POST, context.auth, `${bulkUtils.BULK_API_PATH}/query`, { + operation: context.propsValue.operation, + query, + }); + const job = bulkUtils.formatJob(response.body); + return { job_id: job['id'], state: job['state'], operation: job['operation'], object: job['object'] }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-campaign.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-campaign.ts new file mode 100644 index 000000000000..ee6c4dcb60ce --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-campaign.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createCampaignOutputSchema } from '../../output-schemas'; + +export const createCampaign = createAction({ + auth: salesforceAuth, + name: 'create_campaign', + classification: 'WRITE', + displayName: 'Create Campaign', + description: 'Create a new marketing campaign.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Campaign with a required Name and optional type, status, start/end dates (YYYY-MM-DD), active flag, budget, expected revenue and parent campaign; custom fields go in Additional Fields. Use Update Campaign to change one, and Add Lead to Campaign / Add Contact to Campaign to enroll members. The user needs the Marketing User permission. Not idempotent: each call creates a new Campaign.', + idempotent: false, + }, + outputSchema: createCampaignOutputSchema, + props: { + Name: Property.ShortText({ displayName: 'Name', required: true }), + Type: Property.ShortText({ displayName: 'Type', description: 'Campaign type picklist value, e.g. Webinar or Email.', required: false }), + Status: Property.ShortText({ displayName: 'Status', description: 'Campaign status picklist value, e.g. Planned.', required: false }), + StartDate: Property.ShortText({ displayName: 'Start Date', description: 'YYYY-MM-DD.', required: false }), + EndDate: Property.ShortText({ displayName: 'End Date', description: 'YYYY-MM-DD.', required: false }), + IsActive: Property.Checkbox({ displayName: 'Active', required: false }), + BudgetedCost: Property.Number({ displayName: 'Budgeted Cost', required: false }), + ExpectedRevenue: Property.Number({ displayName: 'Expected Revenue', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + ParentId: Property.ShortText({ displayName: 'Parent Campaign ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'Campaign', + fields: { + ...fields, + StartDate: crmUtils.assertDate({ value: fields.StartDate, fieldName: 'Start Date' }), + EndDate: crmUtils.assertDate({ value: fields.EndDate, fieldName: 'End Date' }), + }, + additionalFields: additional_fields, + }); + return { ...result, name: fields.Name }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-record-tree.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-record-tree.ts new file mode 100644 index 000000000000..817f665fbd83 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-record-tree.ts @@ -0,0 +1,90 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { recordsUtils } from '../../common/records'; +import { createRecordTreeOutputSchema } from '../../output-schemas'; + +export const createRecordTree = createAction({ + auth: salesforceAuth, + name: 'create_record_tree', + classification: 'WRITE', + displayName: 'Create Record Tree', + description: 'Create parent records together with nested child records in one call.', + audience: 'ai', + aiMetadata: { + description: + 'Creates parent records of one object together with nested child records (e.g. an Account with its Contacts and Opportunities) in a single all-or-nothing call, returning the new id for each referenceId. Use it when children must be linked to parents created in the same step; for flat inserts use Create Records Batch. Limits: 200 records in total across all levels, up to 5 levels deep, insert only. Not idempotent: every call creates new records.', + idempotent: false, + }, + outputSchema: createRecordTreeOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Root Object', + description: 'API name of the top-level records, e.g. Account.', + required: true, + }), + records: Property.Json({ + displayName: 'Records', + description: + 'JSON array of top-level records. Nest children under their relationship name, e.g. [{"Name": "Acme", "Contacts": {"records": [{"attributes": {"type": "Contact", "referenceId": "c1"}, "LastName": "Doe"}]}}]. Top-level attributes are filled in when omitted.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Root Object' }); + const records = recordsUtils.parseRecordList({ + value: context.propsValue.records, + fieldName: 'Records', + max: recordsUtils.MAX_COMPOSITE_RECORDS, + }); + const total = countTreeRecords(records); + if (total > recordsUtils.MAX_COMPOSITE_RECORDS) { + throw new Error(`A record tree can hold at most ${recordsUtils.MAX_COMPOSITE_RECORDS} records in total, including children; this one has ${total}.`); + } + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + `/services/data/v56.0/composite/tree/${object}`, + { records: records.map((record, index) => withAttributes({ record, object, index })) } + ); + return { + has_errors: response.body.hasErrors, + results: response.body.results.map((result) => ({ + reference_id: result.referenceId, + id: result.id ?? null, + errors: (result.errors ?? []).map((error) => `${error.statusCode}: ${error.message}`).join('; '), + })), + }; + }, +}); + +function countTreeRecords(records: unknown[]): number { + return records.reduce((total, record) => { + if (!salesforceUtils.isRecord(record)) { + return total + 1; + } + const nested = Object.values(record) + .filter((value): value is Record => salesforceUtils.isRecord(value) && Array.isArray(value['records'])) + .reduce((sum, value) => sum + countTreeRecords(Array.isArray(value['records']) ? value['records'] : []), 0); + return total + 1 + nested; + }, 0); +} + +function withAttributes({ record, object, index }: { record: Record; object: string; index: number }) { + const attributes = salesforceUtils.isRecord(record['attributes']) ? record['attributes'] : {}; + return { + ...record, + attributes: { type: object, referenceId: `ref${index + 1}`, ...attributes }, + }; +} + +type TreeResponse = { + hasErrors: boolean; + results: { + referenceId: string; + id?: string; + errors?: { statusCode: string; message: string; fields?: string[] }[]; + }[]; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-records-batch.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-records-batch.ts new file mode 100644 index 000000000000..baa0eeb92be1 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-records-batch.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { CompositeResult, recordsUtils } from '../../common/records'; +import { compositeBatchOutputSchema } from '../../output-schemas'; + +export const createRecordsBatch = createAction({ + auth: salesforceAuth, + name: 'create_records_batch', + classification: 'WRITE', + displayName: 'Create Records Batch', + description: 'Create up to 200 records of one object in one call.', + audience: 'ai', + aiMetadata: { + description: + 'Creates up to 200 records of one Salesforce object in a single call and returns a per-record result (id or error) in input order. Use it instead of repeated Create Record calls; to create parents with nested children use Create Record Tree, and to avoid duplicates on an external id use Upsert Records Batch. With All or None on, one failure rolls back the whole batch. Not idempotent: every call creates new records.', + idempotent: false, + }, + outputSchema: compositeBatchOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact or My_Object__c.', + required: true, + }), + records: Property.Json({ + displayName: 'Records', + description: 'JSON array of up to 200 objects of field API names to values, e.g. [{"Name": "Acme"}, {"Name": "Globex"}].', + required: true, + }), + all_or_none: Property.Checkbox({ + displayName: 'All or None', + description: 'Roll back every record if any record fails.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const records = recordsUtils.parseRecordList({ + value: context.propsValue.records, + fieldName: 'Records', + max: recordsUtils.MAX_COMPOSITE_RECORDS, + }); + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + '/services/data/v56.0/composite/sobjects', + { + allOrNone: context.propsValue.all_or_none ?? false, + records: records.map((record) => ({ ...record, attributes: { type: object } })), + } + ); + return recordsUtils.formatCompositeResults(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-contact.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-contact.ts new file mode 100644 index 000000000000..48b5dc982492 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-contact.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createSfContactOutputSchema } from '../../output-schemas'; + +export const createSfContact = createAction({ + auth: salesforceAuth, + name: 'create_sf_contact', + classification: 'WRITE', + displayName: 'Create Contact', + description: 'Create a new contact.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Contact with a required Last Name and optional name, email, phones, title, department, account link, mailing location, lead source and owner; custom fields go in Additional Fields. Use Search Contacts first to avoid duplicates and Update Contact to change an existing one; for unqualified prospects use Create Lead instead. Not idempotent: each call creates a new Contact.', + idempotent: false, + }, + outputSchema: createSfContactOutputSchema, + props: { + LastName: Property.ShortText({ displayName: 'Last Name', required: true }), + FirstName: Property.ShortText({ displayName: 'First Name', required: false }), + Email: Property.ShortText({ displayName: 'Email', required: false }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + MobilePhone: Property.ShortText({ displayName: 'Mobile Phone', required: false }), + Title: Property.ShortText({ displayName: 'Title', required: false }), + Department: Property.ShortText({ displayName: 'Department', required: false }), + AccountId: Property.ShortText({ displayName: 'Account ID', required: false }), + MailingCity: Property.ShortText({ displayName: 'Mailing City', required: false }), + MailingCountry: Property.ShortText({ displayName: 'Mailing Country', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', description: 'Lead source picklist value, e.g. Web.', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const result = await crmUtils.createRecord({ auth: context.auth, object: 'Contact', fields, additionalFields: additional_fields }); + return { ...result, last_name: fields.LastName, email: fields.Email ?? null }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-lead.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-lead.ts new file mode 100644 index 000000000000..dadc2a410f64 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-lead.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createSfLeadOutputSchema } from '../../output-schemas'; + +export const createSfLead = createAction({ + auth: salesforceAuth, + name: 'create_sf_lead', + classification: 'WRITE', + displayName: 'Create Lead', + description: 'Create a new lead.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Lead with a required Last Name and Company plus optional contact details, status, lead source, industry, website, location and owner; custom fields go in Additional Fields. Use Search Leads first to avoid duplicates, Update Lead to change one, and Apply Lead Assignment Rules to route it to an owner. Not idempotent: each call creates a new Lead.', + idempotent: false, + }, + outputSchema: createSfLeadOutputSchema, + props: { + LastName: Property.ShortText({ displayName: 'Last Name', required: true }), + Company: Property.ShortText({ displayName: 'Company', required: true }), + FirstName: Property.ShortText({ displayName: 'First Name', required: false }), + Email: Property.ShortText({ displayName: 'Email', required: false }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + Title: Property.ShortText({ displayName: 'Title', required: false }), + Status: Property.ShortText({ displayName: 'Status', description: 'Lead status picklist value, e.g. Open - Not Contacted.', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', required: false }), + Industry: Property.ShortText({ displayName: 'Industry', required: false }), + Website: Property.ShortText({ displayName: 'Website', required: false }), + City: Property.ShortText({ displayName: 'City', required: false }), + Country: Property.ShortText({ displayName: 'Country', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', description: 'User or queue id.', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const result = await crmUtils.createRecord({ auth: context.auth, object: 'Lead', fields, additionalFields: additional_fields }); + return { ...result, last_name: fields.LastName, company: fields.Company }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-note.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-note.ts new file mode 100644 index 000000000000..c34290fa7db0 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-note.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createSfNoteOutputSchema } from '../../output-schemas'; + +export const createSfNote = createAction({ + auth: salesforceAuth, + name: 'create_sf_note', + classification: 'WRITE', + displayName: 'Create Note', + description: 'Attach a note to a record.', + audience: 'ai', + aiMetadata: { + description: + 'Attaches a classic Salesforce Note (title and plain-text body, up to 32 KB) to any record such as an Account, Contact, Lead, Opportunity or Case, optionally private to the owner. Use Create Task or Log Call for activities instead of free-text notes. Not idempotent: each call adds another note.', + idempotent: false, + }, + outputSchema: createSfNoteOutputSchema, + props: { + ParentId: Property.ShortText({ displayName: 'Parent Record ID', description: 'The record the note belongs to.', required: true }), + Title: Property.ShortText({ displayName: 'Title', required: true }), + Body: Property.LongText({ displayName: 'Body', required: false }), + IsPrivate: Property.Checkbox({ displayName: 'Private', description: 'Only the owner and admins can see it.', required: false }), + }, + async run(context) { + const fields = context.propsValue; + const result = await crmUtils.createRecord({ auth: context.auth, object: 'Note', fields, additionalFields: undefined }); + return { ...result, parent_id: fields.ParentId, title: fields.Title }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-opportunity.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-opportunity.ts new file mode 100644 index 000000000000..b71c1ef868fa --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-opportunity.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createSfOpportunityOutputSchema } from '../../output-schemas'; + +export const createSfOpportunity = createAction({ + auth: salesforceAuth, + name: 'create_sf_opportunity', + classification: 'WRITE', + displayName: 'Create Opportunity', + description: 'Create a new opportunity.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Opportunity with a required Name, Stage and Close Date (YYYY-MM-DD) plus optional account, amount, probability, type, source, next step, owner and price book; custom fields go in Additional Fields. Use Update Opportunity to change one and Add Opportunity Product to add line items (set Price Book ID here or it must be set before adding products). Not idempotent: each call creates a new Opportunity.', + idempotent: false, + }, + outputSchema: createSfOpportunityOutputSchema, + props: { + Name: Property.ShortText({ displayName: 'Name', required: true }), + StageName: Property.ShortText({ displayName: 'Stage', description: 'Stage picklist value, e.g. Prospecting.', required: true }), + CloseDate: Property.ShortText({ displayName: 'Close Date', description: 'YYYY-MM-DD.', required: true }), + AccountId: Property.ShortText({ displayName: 'Account ID', required: false }), + Amount: Property.Number({ displayName: 'Amount', required: false }), + Probability: Property.Number({ displayName: 'Probability (%)', required: false }), + Type: Property.ShortText({ displayName: 'Type', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', required: false }), + NextStep: Property.ShortText({ displayName: 'Next Step', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + Pricebook2Id: Property.ShortText({ displayName: 'Price Book ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const closeDate = crmUtils.assertDate({ value: fields.CloseDate, fieldName: 'Close Date' }); + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'Opportunity', + fields: { ...fields, CloseDate: closeDate }, + additionalFields: additional_fields, + }); + return { ...result, name: fields.Name, stage: fields.StageName, close_date: closeDate }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-task.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-task.ts new file mode 100644 index 000000000000..9fdf8cce555a --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sf-task.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { createSfTaskOutputSchema } from '../../output-schemas'; + +export const createSfTask = createAction({ + auth: salesforceAuth, + name: 'create_sf_task', + classification: 'WRITE', + displayName: 'Create Task', + description: 'Create a new task (to-do) in Salesforce.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Salesforce Task with a required Subject, optionally linked to a Contact or Lead (Who ID) and to an Account, Opportunity or other record (What ID), with owner, due date (YYYY-MM-DD), status, priority and description. Use Log Call to record a call that already happened, and Complete Task to close one. Not idempotent: each call creates a new Task.', + idempotent: false, + }, + outputSchema: createSfTaskOutputSchema, + props: { + Subject: Property.ShortText({ displayName: 'Subject', required: true }), + WhoId: Property.ShortText({ displayName: 'Who ID', description: 'Contact or Lead id.', required: false }), + WhatId: Property.ShortText({ displayName: 'What ID', description: 'Account, Opportunity, Case or other record id.', required: false }), + OwnerId: Property.ShortText({ displayName: 'Assigned To (User ID)', required: false }), + ActivityDate: Property.ShortText({ displayName: 'Due Date', description: 'YYYY-MM-DD.', required: false }), + Status: Property.ShortText({ displayName: 'Status', description: 'Task status picklist value, e.g. Not Started.', required: false }), + Priority: Property.ShortText({ displayName: 'Priority', description: 'e.g. High, Normal or Low.', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { additional_fields, ...fields } = context.propsValue; + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'Task', + fields: { ...fields, ActivityDate: crmUtils.assertDate({ value: fields.ActivityDate, fieldName: 'Due Date' }) }, + additionalFields: additional_fields, + }); + return { ...result, subject: fields.Subject }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/create-sobject-record.ts b/packages/pieces/community/salesforce/src/lib/action/ai/create-sobject-record.ts new file mode 100644 index 000000000000..5a9d0a987992 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/create-sobject-record.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { createSobjectRecordOutputSchema } from '../../output-schemas'; + +export const createSobjectRecord = createAction({ + auth: salesforceAuth, + name: 'create_sobject_record', + classification: 'WRITE', + displayName: 'Create Record', + description: 'Create one record of any object.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a single record of any Salesforce object (standard or custom) from a JSON object of field API names to values. Use it for one record; for up to 200 records in one call use Create Records Batch, and to create-or-update on an external id use Upsert Record by External ID. Required fields depend on the object (see Describe Object). Not idempotent: every call creates a new record.', + idempotent: false, + }, + outputSchema: createSobjectRecordOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact, Lead or My_Object__c.', + required: true, + }), + fields: Property.Json({ + displayName: 'Fields', + description: 'JSON object of field API names to values, e.g. {"Name": "Acme", "Industry": "Technology"}.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const fields = salesforceUtils.parseJsonObject({ value: context.propsValue.fields, fieldName: 'Fields' }) ?? {}; + if (Object.keys(fields).length === 0) { + throw new Error('Fields must contain at least one field.'); + } + const response = await callSalesforceApi<{ id: string; success: boolean }>( + HttpMethod.POST, + context.auth, + `/services/data/v56.0/sobjects/${object}`, + fields + ); + return { id: response.body.id, success: response.body.success, object }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/delete-file.ts b/packages/pieces/community/salesforce/src/lib/action/ai/delete-file.ts new file mode 100644 index 000000000000..1d832a0ecb38 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/delete-file.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { deletedRecordOutputSchema } from '../../output-schemas'; + +export const deleteFile = createAction({ + auth: salesforceAuth, + name: 'delete_file', + classification: 'DESTRUCTIVE', + displayName: 'Delete File', + description: 'Delete a Salesforce file and all its versions.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a Salesforce File, all of its versions and its links to records, by its content document id (starts with 069). Destructive: confirm the id with Get File Info first; the file goes to the Recycle Bin and disappears from every record it was shared on. Not idempotent: a second call fails because the file no longer exists.', + idempotent: false, + }, + outputSchema: deletedRecordOutputSchema, + props: { + content_document_id: Property.ShortText({ + displayName: 'Content Document ID', + description: 'File id starting with 069.', + required: true, + }), + }, + async run(context) { + const id = salesforceUtils.assertId({ + value: context.propsValue.content_document_id, + fieldName: 'Content Document ID', + }); + await callSalesforceApi(HttpMethod.DELETE, context.auth, `/services/data/v56.0/connect/files/${id}`, undefined); + return { id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/delete-records-batch.ts b/packages/pieces/community/salesforce/src/lib/action/ai/delete-records-batch.ts new file mode 100644 index 000000000000..8622aec61d8d --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/delete-records-batch.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { CompositeResult, recordsUtils } from '../../common/records'; +import { compositeBatchOutputSchema } from '../../output-schemas'; + +export const deleteRecordsBatch = createAction({ + auth: salesforceAuth, + name: 'delete_records_batch', + classification: 'DESTRUCTIVE', + displayName: 'Delete Records Batch', + description: 'Delete up to 200 records in one call.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes up to 200 Salesforce records by id in a single call (ids may belong to different objects) and returns a per-record result in input order; deleted records go to the Recycle Bin. For a single record use Delete Record. With All or None on, one failure rolls back every delete. Not idempotent: already-deleted ids fail on a second call.', + idempotent: false, + }, + outputSchema: compositeBatchOutputSchema, + props: { + record_ids: Property.Array({ + displayName: 'Record IDs', + description: 'Up to 200 record ids to delete.', + required: true, + }), + all_or_none: Property.Checkbox({ + displayName: 'All or None', + description: 'Roll back every delete if any delete fails.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const ids = recordsUtils.parseIdList({ + value: context.propsValue.record_ids, + fieldName: 'Record IDs', + max: recordsUtils.MAX_COMPOSITE_RECORDS, + }); + const allOrNone = context.propsValue.all_or_none ?? false; + const response = await callSalesforceApi( + HttpMethod.DELETE, + context.auth, + `/services/data/v56.0/composite/sobjects?ids=${ids.join(',')}&allOrNone=${allOrNone}`, + undefined + ); + return recordsUtils.formatCompositeResults(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/delete-sobject-record.ts b/packages/pieces/community/salesforce/src/lib/action/ai/delete-sobject-record.ts new file mode 100644 index 000000000000..7c2d47b80753 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/delete-sobject-record.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { deletedRecordOutputSchema } from '../../output-schemas'; + +export const deleteSobjectRecord = createAction({ + auth: salesforceAuth, + name: 'delete_sobject_record', + classification: 'DESTRUCTIVE', + displayName: 'Delete Record', + description: 'Delete one record of any object.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a single record of any Salesforce object by id; the record goes to the Recycle Bin (recoverable for about 15 days) and cascade-deletes master-detail children. For up to 200 ids in one call use Delete Records Batch. Not idempotent: a second call on the same id fails because the record is already deleted.', + idempotent: false, + }, + outputSchema: deletedRecordOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact or My_Object__c.', + required: true, + }), + record_id: Property.ShortText({ + displayName: 'Record ID', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const recordId = salesforceUtils.assertId({ value: context.propsValue.record_id, fieldName: 'Record ID' }); + await callSalesforceApi( + HttpMethod.DELETE, + context.auth, + `/services/data/v56.0/sobjects/${object}/${recordId}`, + undefined + ); + return { id: recordId, deleted: true }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/describe-object.ts b/packages/pieces/community/salesforce/src/lib/action/ai/describe-object.ts new file mode 100644 index 000000000000..d6fdd83619d5 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/describe-object.ts @@ -0,0 +1,105 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { describeObjectOutputSchema } from '../../output-schemas'; + +export const describeObject = createAction({ + auth: salesforceAuth, + name: 'describe_object', + classification: 'READ', + displayName: 'Describe Object', + description: 'Get the fields, relationships and record types of one object.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the schema of one Salesforce object: every field (API name, label, type, required-ness, create/update permission, external id flag, lookup targets, active picklist values), its child relationships and its record types. Call it before writing SOQL or creating/updating records to get exact field API names and valid picklist values; to find the object API name first use List Objects. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: describeObjectOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Opportunity or My_Object__c.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/describe`, + undefined + ); + const body = response.body; + return { + name: body.name, + label: body.label, + custom: body.custom, + key_prefix: body.keyPrefix, + fields: body.fields.map((field) => ({ + name: field.name, + label: field.label, + type: field.type, + length: field.length, + nillable: field.nillable, + createable: field.createable, + updateable: field.updateable, + custom: field.custom, + external_id: field.externalId, + reference_to: field.referenceTo, + picklist_values: field.picklistValues + .filter((value) => value.active) + .map((value) => ({ value: value.value, label: value.label })), + })), + child_relationships: body.childRelationships + .filter((relationship) => relationship.relationshipName !== null) + .map((relationship) => ({ + relationship_name: relationship.relationshipName, + child_object: relationship.childSObject, + field: relationship.field, + })), + record_types: body.recordTypeInfos.map((recordType) => ({ + name: recordType.name, + developer_name: recordType.developerName, + record_type_id: recordType.recordTypeId, + active: recordType.active, + master: recordType.master, + })), + }; + }, +}); + +type DescribeResult = { + name: string; + label: string; + custom: boolean; + keyPrefix: string | null; + fields: { + name: string; + label: string; + type: string; + length: number; + nillable: boolean; + createable: boolean; + updateable: boolean; + custom: boolean; + externalId: boolean; + referenceTo: string[]; + picklistValues: { value: string; label: string; active: boolean }[]; + }[]; + childRelationships: { + relationshipName: string | null; + childSObject: string; + field: string; + }[]; + recordTypeInfos: { + name: string; + developerName: string; + recordTypeId: string; + active: boolean; + master: boolean; + }[]; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/download-file.ts b/packages/pieces/community/salesforce/src/lib/action/ai/download-file.ts new file mode 100644 index 000000000000..a5864682625b --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/download-file.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { downloadFileOutputSchema } from '../../output-schemas'; + +export const downloadFile = createAction({ + auth: salesforceAuth, + name: 'download_file', + classification: 'READ', + displayName: 'Download File', + description: 'Download the content of a Salesforce file.', + audience: 'ai', + aiMetadata: { + description: + 'Downloads the latest version of a Salesforce File by its content document id (starts with 069) and returns it as a file reference with its name, size and MIME type. Use Get File Info when you only need metadata. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: downloadFileOutputSchema, + props: { + content_document_id: Property.ShortText({ + displayName: 'Content Document ID', + description: 'File id starting with 069.', + required: true, + }), + }, + async run(context) { + const id = salesforceUtils.assertId({ + value: context.propsValue.content_document_id, + fieldName: 'Content Document ID', + }); + const info = await callSalesforceApi<{ title: string; fileExtension?: string; mimeType?: string }>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/connect/files/${id}`, + undefined, + ); + const content = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/connect/files/${id}/content`, + undefined, + { responseType: 'arraybuffer' }, + ); + const fileName = buildFileName({ title: info.body.title, extension: info.body.fileExtension }); + const data = Buffer.from(content.body); + return { + file: await context.files.write({ fileName, data }), + file_name: fileName, + size: data.length, + mime_type: info.body.mimeType ?? null, + }; + }, +}); + +function buildFileName({ title, extension }: { title: string; extension: string | undefined }): string { + if (!extension || title.toLowerCase().endsWith(`.${extension.toLowerCase()}`)) { + return title; + } + return `${title}.${extension}`; +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-job.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-job.ts new file mode 100644 index 000000000000..9aaa2bc5adb4 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-job.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { bulkUtils } from '../../common/bulk'; +import { bulkJobOutputSchema } from '../../output-schemas'; + +export const getBulkIngestJob = createAction({ + auth: salesforceAuth, + name: 'get_bulk_ingest_job', + classification: 'READ', + displayName: 'Get Bulk Ingest Job', + description: 'Get the status and counters of a Bulk API 2.0 ingest job.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the state and processed/failed record counts of a Bulk API 2.0 ingest job started by Run Bulk Ingest Job. Poll it until the state is JobComplete, Failed or Aborted, then call Get Bulk Ingest Results. For query jobs use Get Bulk Query Job. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: bulkJobOutputSchema, + props: { + job_id: Property.ShortText({ + displayName: 'Job ID', + required: true, + }), + }, + async run(context) { + const jobId = salesforceUtils.assertId({ value: context.propsValue.job_id, fieldName: 'Job ID' }); + const response = await callSalesforceApi(HttpMethod.GET, context.auth, `${bulkUtils.BULK_API_PATH}/ingest/${jobId}`, undefined); + return bulkUtils.formatJob(response.body); + }, +}); + diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-results.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-results.ts new file mode 100644 index 000000000000..4255b6c7ef45 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-ingest-results.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { bulkUtils } from '../../common/bulk'; +import { getBulkIngestResultsOutputSchema } from '../../output-schemas'; + +export const getBulkIngestResults = createAction({ + auth: salesforceAuth, + name: 'get_bulk_ingest_results', + classification: 'READ', + displayName: 'Get Bulk Ingest Results', + description: 'Get the successful, failed or unprocessed rows of a Bulk API 2.0 ingest job.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the per-row outcome of a Bulk API 2.0 ingest job as rows: successful rows carry sf__Id and sf__Created, failed rows carry sf__Error. Results only exist once Get Bulk Ingest Job reports JobComplete or Failed; unprocessed rows are those never attempted (e.g. after an abort). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getBulkIngestResultsOutputSchema, + props: { + job_id: Property.ShortText({ + displayName: 'Job ID', + required: true, + }), + result_type: Property.StaticDropdown({ + displayName: 'Result Type', + required: true, + defaultValue: 'successfulResults', + options: { + options: [ + { label: 'Successful', value: 'successfulResults' }, + { label: 'Failed', value: 'failedResults' }, + { label: 'Unprocessed', value: 'unprocessedrecords' }, + ], + }, + }), + }, + async run(context) { + const jobId = salesforceUtils.assertId({ value: context.propsValue.job_id, fieldName: 'Job ID' }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `${bulkUtils.BULK_API_PATH}/ingest/${jobId}/${context.propsValue.result_type}`, + undefined, + { responseType: 'text' } + ); + const rows = bulkUtils.parseCsv(typeof response.body === 'string' ? response.body : ''); + return { rows, count: rows.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-job.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-job.ts new file mode 100644 index 000000000000..b92abe744eb8 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-job.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { bulkUtils } from '../../common/bulk'; +import { bulkJobOutputSchema } from '../../output-schemas'; + +export const getBulkQueryJob = createAction({ + auth: salesforceAuth, + name: 'get_bulk_query_job', + classification: 'READ', + displayName: 'Get Bulk Query Job', + description: 'Get the status of a Bulk API 2.0 query job.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the state and processed record count of a Bulk API 2.0 query job started by Create Bulk Query Job. Poll it until the state is JobComplete (then call Get Bulk Query Results), Failed or Aborted. For ingest jobs use Get Bulk Ingest Job. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: bulkJobOutputSchema, + props: { + job_id: Property.ShortText({ + displayName: 'Job ID', + required: true, + }), + }, + async run(context) { + const jobId = salesforceUtils.assertId({ value: context.propsValue.job_id, fieldName: 'Job ID' }); + const response = await callSalesforceApi(HttpMethod.GET, context.auth, `${bulkUtils.BULK_API_PATH}/query/${jobId}`, undefined); + return bulkUtils.formatJob(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-results.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-results.ts new file mode 100644 index 000000000000..9e613464d188 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-bulk-query-results.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { bulkUtils } from '../../common/bulk'; +import { getBulkQueryResultsOutputSchema } from '../../output-schemas'; + +export const getBulkQueryResults = createAction({ + auth: salesforceAuth, + name: 'get_bulk_query_results', + classification: 'READ', + displayName: 'Get Bulk Query Results', + description: 'Get one page of rows from a completed Bulk API 2.0 query job.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of rows from a Bulk API 2.0 query job once Get Bulk Query Job reports JobComplete. Pass the returned next_locator to fetch the next page; next_locator is null when there are no more rows. Page size defaults to 1000 and is capped at 10000. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getBulkQueryResultsOutputSchema, + props: { + job_id: Property.ShortText({ + displayName: 'Job ID', + required: true, + }), + locator: Property.ShortText({ + displayName: 'Locator', + description: 'next_locator from the previous page. Leave empty for the first page.', + required: false, + }), + max_records: Property.Number({ + displayName: 'Max Records', + description: 'Rows per page (default 1000, max 10000).', + required: false, + defaultValue: 1000, + }), + }, + async run(context) { + const jobId = salesforceUtils.assertId({ value: context.propsValue.job_id, fieldName: 'Job ID' }); + const maxRecords = Math.min(Math.max(Math.floor(context.propsValue.max_records ?? 1000), 1), 10000); + const locator = context.propsValue.locator?.trim(); + const params = new URLSearchParams({ maxRecords: String(maxRecords), ...(locator ? { locator } : {}) }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `${bulkUtils.BULK_API_PATH}/query/${jobId}/results?${params.toString()}`, + undefined, + { responseType: 'text' } + ); + const rows = bulkUtils.parseCsv(typeof response.body === 'string' ? response.body : ''); + const header = response.headers?.['sforce-locator']; + const nextLocator = Array.isArray(header) ? header[0] : header; + return { + rows, + count: rows.length, + next_locator: !nextLocator || nextLocator === 'null' ? null : nextLocator, + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-child-records.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-child-records.ts new file mode 100644 index 000000000000..06a6f93f899a --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-child-records.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { soqlQueryOutputSchema } from '../../output-schemas'; + +export const getChildRecords = createAction({ + auth: salesforceAuth, + name: 'get_child_records', + classification: 'READ', + displayName: 'Get Child Records', + description: 'List the related child records of a parent record.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the child records under one parent record through a child relationship name, e.g. Contacts or Opportunities on an Account, or Cases on a Contact. Relationship names come from Describe Object (childRelationships); for filtered or sorted child lookups use Run SOQL Query instead. If next_records_url is not null, fetch more with Get Next Query Page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: soqlQueryOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Parent Object', + description: 'Parent object API name, e.g. Account.', + required: true, + }), + record_id: Property.ShortText({ + displayName: 'Parent Record ID', + required: true, + }), + relationship_name: Property.ShortText({ + displayName: 'Relationship Name', + description: 'Child relationship name, e.g. Contacts, Opportunities or My_Children__r.', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Child field API names to return. Leave empty for all fields.', + required: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Parent Object' }); + const recordId = salesforceUtils.assertId({ value: context.propsValue.record_id, fieldName: 'Parent Record ID' }); + const relationship = salesforceUtils.assertApiName({ + value: context.propsValue.relationship_name, + fieldName: 'Relationship Name', + }); + const fields = salesforceUtils + .toStringArray(context.propsValue.fields) + .map((field) => salesforceUtils.assertApiName({ value: field, fieldName: 'Fields' })); + const query = fields.length > 0 ? `?fields=${fields.join(',')}` : ''; + const response = await callSalesforceApi>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/${recordId}/${relationship}${query}`, + undefined + ); + return salesforceUtils.formatQueryResult(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-current-user.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-current-user.ts new file mode 100644 index 000000000000..99def7921951 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-current-user.ts @@ -0,0 +1,51 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { getCurrentUserOutputSchema } from '../../output-schemas'; + +export const getCurrentUser = createAction({ + auth: salesforceAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Get the connected Salesforce user and org.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the Salesforce user behind this connection (user id, username, name, email, locale, time zone, user type) and the org id. Use it to resolve "me" or "my" in a request, e.g. to filter by OwnerId in Run SOQL Query or to assign records to the current user. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getCurrentUserOutputSchema, + props: {}, + async run(context) { + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + '/services/oauth2/userinfo', + undefined + ); + const user = response.body; + return { + user_id: user.user_id, + organization_id: user.organization_id, + username: user.preferred_username, + name: user.name, + email: user.email, + locale: user.locale, + zoneinfo: user.zoneinfo, + user_type: user.user_type, + }; + }, +}); + +type UserInfo = { + user_id: string; + organization_id: string; + preferred_username: string; + name: string; + email: string; + locale: string; + zoneinfo: string; + user_type: string; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-dashboard.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-dashboard.ts new file mode 100644 index 000000000000..f3f26706fb36 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-dashboard.ts @@ -0,0 +1,83 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getDashboardOutputSchema } from '../../output-schemas'; + +export const getDashboard = createAction({ + auth: salesforceAuth, + name: 'get_dashboard', + classification: 'READ', + displayName: 'Get Dashboard', + description: 'Get a dashboard with its components and filters.', + audience: 'ai', + aiMetadata: { + description: + 'Describes a Salesforce dashboard by id: name, folder, filters and each component with its header, title, type and source report id. Use the report ids with Run Report Sync to get the underlying data; use List Dashboards to find the id. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getDashboardOutputSchema, + props: { + dashboard_id: Property.ShortText({ + displayName: 'Dashboard ID', + description: 'Dashboard id starting with 01Z.', + required: true, + }), + }, + async run(context) { + const dashboardId = salesforceUtils.assertId({ + value: context.propsValue.dashboard_id, + fieldName: 'Dashboard ID', + }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/analytics/dashboards/${dashboardId}/describe`, + undefined, + ); + const dashboard = response.body; + return { + id: dashboard.id ?? dashboardId, + name: dashboard.name ?? null, + developer_name: dashboard.developerName ?? null, + folder_id: dashboard.folderId ?? null, + components: (dashboard.components ?? []).map((component) => ({ + id: component.id, + header: component.header ?? null, + title: component.title ?? null, + type: component.type ?? null, + visualization_type: component.properties?.visualizationType ?? null, + report_id: component.reportId ?? null, + })), + filters: (dashboard.filters ?? []).map((filter) => ({ + name: filter.name, + options: (filter.options ?? []).map((option) => ({ + id: option.id ?? null, + alias: option.alias ?? null, + operation: option.operation ?? null, + value: option.value ?? null, + })), + })), + }; + }, +}); + +type DashboardDescribe = { + id?: string; + name?: string; + developerName?: string; + folderId?: string; + components?: { + id: string; + header?: string | null; + title?: string | null; + type?: string; + reportId?: string | null; + properties?: { visualizationType?: string }; + }[]; + filters?: { + name: string; + options?: { id?: string; alias?: string | null; operation?: string; value?: string | null }[]; + }[]; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-file-info.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-file-info.ts new file mode 100644 index 000000000000..6ce58ff55f1e --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-file-info.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getFileInfoOutputSchema } from '../../output-schemas'; + +export const getFileInfo = createAction({ + auth: salesforceAuth, + name: 'get_file_info', + classification: 'READ', + displayName: 'Get File Info', + description: 'Get the details of a Salesforce file.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the metadata of a Salesforce File (title, extension, type, size, MIME type, owner, dates, version) by its content document id (starts with 069). Use it to inspect a file; to get the bytes use Download File. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getFileInfoOutputSchema, + props: { + content_document_id: Property.ShortText({ + displayName: 'Content Document ID', + description: 'File id starting with 069.', + required: true, + }), + }, + async run(context) { + const id = salesforceUtils.assertId({ + value: context.propsValue.content_document_id, + fieldName: 'Content Document ID', + }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/connect/files/${id}`, + undefined, + ); + const file = response.body; + return { + id: file.id, + title: file.title, + name: file.name ?? null, + file_extension: file.fileExtension ?? null, + file_type: file.fileType ?? null, + content_size: file.contentSize ?? null, + mime_type: file.mimeType ?? null, + owner_id: file.owner?.id ?? null, + owner_name: file.owner?.displayName ?? file.owner?.name ?? null, + created_date: file.createdDate ?? null, + modified_date: file.modifiedDate ?? null, + version_number: file.versionNumber ?? null, + download_url: file.downloadUrl ?? null, + }; + }, +}); + +type ConnectFile = { + id: string; + title: string; + name?: string; + fileExtension?: string; + fileType?: string; + contentSize?: number; + mimeType?: string; + owner?: { id?: string; displayName?: string; name?: string }; + createdDate?: string; + modifiedDate?: string; + versionNumber?: string; + downloadUrl?: string; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-metadata.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-metadata.ts new file mode 100644 index 000000000000..0e111df17a11 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-metadata.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getListViewMetadataOutputSchema } from '../../output-schemas'; + +export const getListViewMetadata = createAction({ + auth: salesforceAuth, + name: 'get_list_view_metadata', + classification: 'READ', + displayName: 'Get List View Metadata', + description: 'Get the columns, filters and SOQL query behind a list view.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the columns, ordering, filter and underlying SOQL query of one list view (ids from List List Views). Use it to reuse or adapt the list view\'s query; to read its rows directly use Get List View Records. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getListViewMetadataOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account.', + required: true, + }), + list_view_id: Property.ShortText({ + displayName: 'List View ID', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const listViewId = salesforceUtils.assertId({ value: context.propsValue.list_view_id, fieldName: 'List View ID' }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/listviews/${listViewId}/describe`, + undefined + ); + const body: unknown = response.body; + const result = salesforceUtils.isRecord(body) ? body : {}; + const columns = (Array.isArray(result['columns']) ? result['columns'] : []).filter(salesforceUtils.isRecord).map((column) => ({ + field_name_or_path: column['fieldNameOrPath'] ?? null, + label: column['label'] ?? null, + sortable: column['sortable'] ?? null, + type: column['type'] ?? null, + })); + return { + id: result['id'] ?? listViewId, + sobject_type: result['sobjectType'] ?? object, + query: result['query'] ?? null, + columns, + order_by: result['orderBy'] ?? null, + where_condition: result['whereCondition'] ?? null, + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-records.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-records.ts new file mode 100644 index 000000000000..d43d9c0e4c5a --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-list-view-records.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getListViewRecordsOutputSchema } from '../../output-schemas'; + +export const getListViewRecords = createAction({ + auth: salesforceAuth, + name: 'get_list_view_records', + classification: 'READ', + displayName: 'Get List View Records', + description: 'Get the rows a list view shows, with its columns.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the records of one list view (ids from List List Views) as flat rows keyed by the list view\'s columns, exactly as a user sees them. Use it to reuse a saved filter; for custom filtering write a SOQL query instead. Page with Offset; Limit defaults to 50, max 200. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getListViewRecordsOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account.', + required: true, + }), + list_view_id: Property.ShortText({ + displayName: 'List View ID', + required: true, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Rows to return (default 50, max 200).', + required: false, + defaultValue: 50, + }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Rows to skip, for paging.', + required: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const listViewId = salesforceUtils.assertId({ value: context.propsValue.list_view_id, fieldName: 'List View ID' }); + const limit = Math.min(Math.max(Math.floor(context.propsValue.limit ?? 50), 1), 200); + const offset = Math.max(Math.floor(context.propsValue.offset ?? 0), 0); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/listviews/${listViewId}/results?limit=${limit}&offset=${offset}`, + undefined + ); + const body: unknown = response.body; + const result = salesforceUtils.isRecord(body) ? body : {}; + const records = (Array.isArray(result['records']) ? result['records'] : []).filter(salesforceUtils.isRecord).map(flattenRow); + return { + records, + count: records.length, + size: result['size'] ?? null, + done: result['done'] ?? null, + }; + }, +}); + +function flattenRow(row: Record): Record { + const cells = Array.isArray(row['columns']) ? row['columns'] : []; + return Object.fromEntries( + cells.filter(salesforceUtils.isRecord).map((cell) => [String(cell['fieldNameOrPath']), cell['value'] ?? null]) + ); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-next-query-page.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-next-query-page.ts new file mode 100644 index 000000000000..e5ef3bbaa4bd --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-next-query-page.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { soqlQueryOutputSchema } from '../../output-schemas'; + +export const getNextQueryPage = createAction({ + auth: salesforceAuth, + name: 'get_next_query_page', + classification: 'READ', + displayName: 'Get Next Query Page', + description: 'Fetch the next page of a query result.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches the next batch of records for a query whose result had a non-null next_records_url, from Run SOQL Query, Run SOQL Query All, Run Tooling Query or Get Child Records. Pass that next_records_url exactly as returned (or just the query locator); keep calling until next_records_url is null. Read-only; safe to retry while the cursor is alive (about 15 minutes of inactivity).', + idempotent: true, + }, + outputSchema: soqlQueryOutputSchema, + props: { + next_records_url: Property.ShortText({ + displayName: 'Next Records URL', + description: 'The next_records_url from a previous query result, e.g. /services/data/v56.0/query/01gD0000002HU6KIAW-2000.', + required: true, + }), + }, + async run(context) { + const path = toQueryPath(context.propsValue.next_records_url); + const response = await callSalesforceApi>(HttpMethod.GET, context.auth, path, undefined); + return salesforceUtils.formatQueryResult(response.body); + }, +}); + +function toQueryPath(value: string): string { + const trimmed = value.trim(); + if (NEXT_RECORDS_URL_PATTERN.test(trimmed)) { + return trimmed; + } + if (LOCATOR_PATTERN.test(trimmed)) { + return `/services/data/v56.0/query/${trimmed}`; + } + throw new Error('Next Records URL must be a next_records_url returned by a query, such as /services/data/v56.0/query/01g...-2000.'); +} + +const NEXT_RECORDS_URL_PATTERN = /^\/services\/data\/v\d+\.\d+\/(?:tooling\/)?(?:query|queryAll)\/[A-Za-z0-9-]+$/; +const LOCATOR_PATTERN = /^[A-Za-z0-9]+-\d+$/; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-org-limits.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-org-limits.ts new file mode 100644 index 000000000000..5e1cc983086d --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-org-limits.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { getOrgLimitsOutputSchema } from '../../output-schemas'; + +export const getOrgLimits = createAction({ + auth: salesforceAuth, + name: 'get_org_limits', + classification: 'READ', + displayName: 'Get Org Limits', + description: 'Get the org limits and how much of each is used.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the Salesforce org limits (e.g. DailyApiRequests, DataStorageMB, DailyBulkApiBatches) with max, remaining and used values. Use it to check API or storage headroom before heavy work; for per-object record totals use Get Record Counts. Filter with Name to return only matching limits. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getOrgLimitsOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Only return limits whose name contains this text (case-insensitive), e.g. Api or Storage.', + required: false, + }), + }, + async run(context) { + const response = await callSalesforceApi>( + HttpMethod.GET, + context.auth, + '/services/data/v56.0/limits', + undefined + ); + const filter = (context.propsValue.name ?? '').trim().toLowerCase(); + const limits = Object.entries(response.body) + .filter(([name]) => filter.length === 0 || name.toLowerCase().includes(filter)) + .map(([name, limit]) => ({ + name, + max: limit.Max, + remaining: limit.Remaining, + used: limit.Max - limit.Remaining, + })); + return { limits, count: limits.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-quick-action-defaults.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-quick-action-defaults.ts new file mode 100644 index 000000000000..4de7906afe31 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-quick-action-defaults.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getQuickActionDefaultsOutputSchema } from '../../output-schemas'; + +export const getQuickActionDefaults = createAction({ + auth: salesforceAuth, + name: 'get_quick_action_defaults', + classification: 'READ', + displayName: 'Get Quick Action Defaults', + description: 'Get the default field values a quick action would pre-fill.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the field values a quick action pre-fills, optionally in the context of a parent record (e.g. an Account id for Account.NewContact). Call it before Run Quick Action to see which fields to send. Names come from List Quick Actions. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getQuickActionDefaultsOutputSchema, + props: { + action_name: Property.ShortText({ + displayName: 'Quick Action Name', + description: 'Name exactly as returned by List Quick Actions: Object.Action for object-specific actions (e.g. Account.NewContact), a bare name for global ones (e.g. LogACall).', + required: true, + }), + context_id: Property.ShortText({ + displayName: 'Context Record ID', + description: 'Id of the record the action runs against. Optional.', + required: false, + }), + }, + async run(context) { + const actionName = assertQuickActionName(context.propsValue.action_name); + const contextId = context.propsValue.context_id?.trim() + ? `/${salesforceUtils.assertId({ value: context.propsValue.context_id, fieldName: 'Context Record ID' })}` + : ''; + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/${quickActionPath(actionName)}/defaultValues${contextId}`, + undefined + ); + return salesforceUtils.cleanRecord(response.body); + }, +}); + +function quickActionPath(actionName: string): string { + const [objectName] = actionName.split('.'); + return actionName.includes('.') ? `sobjects/${objectName}/quickActions/${actionName}` : `quickActions/${actionName}`; +} + +function assertQuickActionName(value: string): string { + return value + .trim() + .split('.') + .map((part) => salesforceUtils.assertApiName({ value: part, fieldName: 'Quick Action Name' })) + .join('.'); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-record-by-external-id.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-record-by-external-id.ts new file mode 100644 index 000000000000..b0657276ade7 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-record-by-external-id.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { recordOutputSchema } from '../../output-schemas'; + +export const getRecordByExternalId = createAction({ + auth: salesforceAuth, + name: 'get_record_by_external_id', + classification: 'READ', + displayName: 'Get Record by External ID', + description: 'Get one record by the value of an external id field.', + audience: 'ai', + aiMetadata: { + description: + 'Returns a single Salesforce record matched on an external id field (a field marked External ID or unique, e.g. My_Ext_Id__c) instead of the Salesforce id. Use it when you know an id from another system; if you have the Salesforce id use Get Record. Fails with 404 when nothing matches and 300 when several records match. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: recordOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or My_Object__c.', + required: true, + }), + external_id_field: Property.ShortText({ + displayName: 'External ID Field', + description: 'API name of the external id field, e.g. My_Ext_Id__c.', + required: true, + }), + external_id_value: Property.ShortText({ + displayName: 'External ID Value', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field API names to return. Leave empty for all fields.', + required: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const externalField = salesforceUtils.assertApiName({ + value: context.propsValue.external_id_field, + fieldName: 'External ID Field', + }); + const value = context.propsValue.external_id_value.trim(); + if (value.length === 0) { + throw new Error('External ID Value is required.'); + } + const fields = salesforceUtils + .toStringArray(context.propsValue.fields) + .map((field) => salesforceUtils.assertApiName({ value: field, fieldName: 'Fields' })); + const query = fields.length > 0 ? `?fields=${fields.join(',')}` : ''; + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/${externalField}/${encodeURIComponent(value)}${query}`, + undefined + ); + return salesforceUtils.cleanRecord(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-record-counts.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-record-counts.ts new file mode 100644 index 000000000000..31f98dccf974 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-record-counts.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getRecordCountsOutputSchema } from '../../output-schemas'; + +export const getRecordCounts = createAction({ + auth: salesforceAuth, + name: 'get_record_counts', + classification: 'READ', + displayName: 'Get Record Counts', + description: 'Get approximate record counts per object.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the approximate number of records stored for each listed object (or every object when none are listed), as used for org storage. Use it for a quick size overview; counts are refreshed periodically and can lag, so for an exact filtered count use Run SOQL Query with SELECT COUNT(). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getRecordCountsOutputSchema, + props: { + objects: Property.Array({ + displayName: 'Objects', + description: 'Object API names to count, e.g. Account, Contact. Leave empty for all objects.', + required: false, + }), + }, + async run(context) { + const objects = salesforceUtils + .toStringArray(context.propsValue.objects) + .map((object) => salesforceUtils.assertApiName({ value: object, fieldName: 'Objects' })); + const query = objects.length > 0 ? `?sObjects=${objects.join(',')}` : ''; + const response = await callSalesforceApi<{ sObjects: { name: string; count: number }[] }>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/limits/recordCount${query}`, + undefined + ); + const counts = response.body.sObjects.map((item) => ({ name: item.name, count: item.count })); + return { counts, count: counts.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-record.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-record.ts new file mode 100644 index 000000000000..746a2c6f5fce --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-record.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { recordOutputSchema } from '../../output-schemas'; + +export const getRecord = createAction({ + auth: salesforceAuth, + name: 'get_record', + classification: 'READ', + displayName: 'Get Record', + description: 'Get one record of any object by its id.', + audience: 'ai', + aiMetadata: { + description: + 'Returns a single Salesforce record of any object (Account, Contact, Lead, Opportunity, Case, custom objects…) by its 15- or 18-character id, with all fields or only the ones you list. Use it after a search or query returned an id; to fetch many records of one object at once use Get Records Batch. Field and object names are API names from Describe Object. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: recordOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact, Opportunity or My_Object__c.', + required: true, + }), + record_id: Property.ShortText({ + displayName: 'Record ID', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field API names to return. Leave empty for all fields.', + required: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const recordId = salesforceUtils.assertId({ value: context.propsValue.record_id, fieldName: 'Record ID' }); + const fields = salesforceUtils + .toStringArray(context.propsValue.fields) + .map((field) => salesforceUtils.assertApiName({ value: field, fieldName: 'Fields' })); + const query = fields.length > 0 ? `?fields=${fields.join(',')}` : ''; + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/${recordId}${query}`, + undefined + ); + return salesforceUtils.cleanRecord(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-records-batch.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-records-batch.ts new file mode 100644 index 000000000000..56f3e9181f0a --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-records-batch.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { recordsUtils } from '../../common/records'; +import { getRecordsBatchOutputSchema } from '../../output-schemas'; + +export const getRecordsBatch = createAction({ + auth: salesforceAuth, + name: 'get_records_batch', + classification: 'READ', + displayName: 'Get Records Batch', + description: 'Get up to 2000 records of one object by their ids.', + audience: 'ai', + aiMetadata: { + description: + 'Returns up to 2000 records of one Salesforce object by id in a single call, with the fields you list (at least one field is required). Use it instead of calling Get Record repeatedly; to find records by criteria use Run SOQL Query. Ids that do not exist come back as null entries in the same position, so the output stays aligned with the input. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getRecordsBatchOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact or My_Object__c.', + required: true, + }), + record_ids: Property.Array({ + displayName: 'Record IDs', + description: 'Up to 2000 record ids of this object.', + required: true, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field API names to return, e.g. Id, Name. At least one is required.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const ids = recordsUtils.parseIdList({ value: context.propsValue.record_ids, fieldName: 'Record IDs', max: MAX_IDS }); + const fields = salesforceUtils + .toStringArray(context.propsValue.fields) + .map((field) => salesforceUtils.assertApiName({ value: field, fieldName: 'Fields' })); + if (fields.length === 0) { + throw new Error('Fields must contain at least one field API name.'); + } + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + `/services/data/v56.0/composite/sobjects/${object}`, + { ids, fields } + ); + const records = response.body.map((record, index) => ({ + requested_id: ids[index], + found: record !== null, + record: salesforceUtils.cleanRecord(record), + })); + return { + records, + count: records.length, + found_count: records.filter((record) => record.found).length, + }; + }, +}); + +const MAX_IDS = 2000; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-report-instance.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-report-instance.ts new file mode 100644 index 000000000000..90a3e622ff25 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-report-instance.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { reportUtils, SalesforceReportResponse } from '../../common/report'; +import { salesforceUtils } from '../../common/utils'; +import { getReportInstanceOutputSchema } from '../../output-schemas'; + +export const getReportInstance = createAction({ + auth: salesforceAuth, + name: 'get_report_instance', + classification: 'READ', + displayName: 'Get Report Instance', + description: 'Check an async report run and get its rows when it is done.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the status of a report run started with Run Report Async (New, Running, Success or Error) and, once the status is Success, its flat rows keyed by column label. Call it again later while the status is New or Running. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getReportInstanceOutputSchema, + props: { + report_id: Property.ShortText({ + displayName: 'Report ID', + description: 'Report id starting with 00O.', + required: true, + }), + instance_id: Property.ShortText({ + displayName: 'Instance ID', + description: 'Instance id returned by Run Report Async.', + required: true, + }), + }, + async run(context) { + const reportId = salesforceUtils.assertId({ value: context.propsValue.report_id, fieldName: 'Report ID' }); + const instanceId = salesforceUtils.assertId({ value: context.propsValue.instance_id, fieldName: 'Instance ID' }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/analytics/reports/${reportId}/instances/${instanceId}`, + undefined, + ); + const status = response.body.attributes?.status ?? 'Unknown'; + if (status !== 'Success') { + return { instance_id: instanceId, status, reportId }; + } + return { + instance_id: instanceId, + status, + ...reportUtils.transformReportToRows(response.body), + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-report.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-report.ts new file mode 100644 index 000000000000..7cc7bf27aa25 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-report.ts @@ -0,0 +1,73 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getReportOutputSchema } from '../../output-schemas'; + +export const getReport = createAction({ + auth: salesforceAuth, + name: 'get_report', + classification: 'READ', + displayName: 'Get Report', + description: 'Get the definition of a report: columns, groupings and filters.', + audience: 'ai', + aiMetadata: { + description: + 'Describes a Salesforce report by id without running it: format, report type, detail columns with labels, groupings, saved filters and filter logic. Use it to learn the column API names before passing filters to Run Report Sync or Run Report Async; use List Reports to find the id. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getReportOutputSchema, + props: { + report_id: Property.ShortText({ + displayName: 'Report ID', + description: 'Report id starting with 00O.', + required: true, + }), + }, + async run(context) { + const reportId = salesforceUtils.assertId({ value: context.propsValue.report_id, fieldName: 'Report ID' }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/analytics/reports/${reportId}/describe`, + undefined, + ); + const metadata = response.body.reportMetadata; + const columnInfo = response.body.reportExtendedMetadata?.detailColumnInfo ?? {}; + return { + id: metadata.id, + name: metadata.name, + developer_name: metadata.developerName ?? null, + format: metadata.reportFormat ?? null, + report_type: metadata.reportType?.type ?? null, + report_type_label: metadata.reportType?.label ?? null, + detail_columns: metadata.detailColumns ?? [], + groupings_down: (metadata.groupingsDown ?? []).map((grouping) => grouping.name), + filters: metadata.reportFilters ?? [], + filter_logic: metadata.reportBooleanFilter ?? null, + columns: Object.entries(columnInfo).map(([apiName, info]) => ({ + api_name: apiName, + label: info.label, + data_type: info.dataType, + })), + }; + }, +}); + +type ReportDescribe = { + reportMetadata: { + id: string; + name: string; + developerName?: string; + reportFormat?: string; + reportType?: { type: string; label: string }; + detailColumns?: string[]; + groupingsDown?: { name: string }[]; + reportFilters?: { column: string; operator: string; value: string }[]; + reportBooleanFilter?: string | null; + }; + reportExtendedMetadata?: { + detailColumnInfo?: Record; + }; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/get-updated-record-ids.ts b/packages/pieces/community/salesforce/src/lib/action/ai/get-updated-record-ids.ts new file mode 100644 index 000000000000..8e1d14f0e3e4 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/get-updated-record-ids.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { getUpdatedRecordIdsOutputSchema } from '../../output-schemas'; + +export const getUpdatedRecordIds = createAction({ + auth: salesforceAuth, + name: 'get_updated_record_ids', + classification: 'READ', + displayName: 'Get Updated Record IDs', + description: 'List ids of records of one object changed in a time window.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the ids of records of one Salesforce object that were created or updated between a start and end time, plus the latest time covered. Use it for incremental sync; to fetch the records afterwards use Get Records Batch, and to filter on other criteria use Run SOQL Query. The window must fall within the last 30 days; times are UTC at minute precision. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: getUpdatedRecordIdsOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or My_Object__c.', + required: true, + }), + start: Property.DateTime({ + displayName: 'Start', + description: 'ISO 8601 start time, within the last 30 days, e.g. 2026-09-01T00:00:00Z.', + required: true, + }), + end: Property.DateTime({ + displayName: 'End', + description: 'ISO 8601 end time. Defaults to now.', + required: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const start = toSalesforceDate({ value: context.propsValue.start, fieldName: 'Start' }); + const end = toSalesforceDate({ value: context.propsValue.end ?? new Date().toISOString(), fieldName: 'End' }); + const response = await callSalesforceApi<{ ids: string[]; latestDateCovered: string }>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/sobjects/${object}/updated/?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`, + undefined + ); + return { + ids: response.body.ids, + count: response.body.ids.length, + latest_date_covered: response.body.latestDateCovered, + }; + }, +}); + +function toSalesforceDate({ value, fieldName }: { value: string; fieldName: string }): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`${fieldName} must be an ISO 8601 date-time.`); + } + return date.toISOString().replace(/\.\d{3}Z$/, '+00:00'); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-dashboards.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-dashboards.ts new file mode 100644 index 000000000000..285c53d53839 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-dashboards.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { querySalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { listDashboardsOutputSchema } from '../../output-schemas'; + +export const listDashboards = createAction({ + auth: salesforceAuth, + name: 'list_dashboards', + classification: 'SEARCH', + displayName: 'List Dashboards', + description: 'List dashboards, optionally filtered by title.', + audience: 'ai', + aiMetadata: { + description: + 'Lists Salesforce dashboards (id, title, developer name, folder, description) filtered by title. Use it to find a dashboard id for Get Dashboard, which lists its components and their source reports. Returns at most 200 dashboards per call. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listDashboardsOutputSchema, + props: { + title_contains: Property.ShortText({ + displayName: 'Title Contains', + description: 'Only dashboards whose title contains this text.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum dashboards to return (1-200, default 50).', + required: false, + }), + }, + async run(context) { + const { title_contains, limit } = context.propsValue; + const where = title_contains ? ` WHERE Title LIKE '%${salesforceUtils.escapeSoqlLike(title_contains)}%'` : ''; + const soql = `SELECT Id, Title, DeveloperName, FolderName, Description, LastModifiedDate FROM Dashboard${where} ORDER BY Title LIMIT ${clampLimit(limit)}`; + const response = await querySalesforceApi>(HttpMethod.GET, context.auth, soql); + return salesforceUtils.formatQueryResult(response.body); + }, +}); + +function clampLimit(limit: number | undefined): number { + return Math.min(Math.max(Math.floor(limit ?? 50), 1), 200); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-email-templates.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-email-templates.ts new file mode 100644 index 000000000000..ca668e82f2f3 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-email-templates.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { querySalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { listEmailTemplatesOutputSchema } from '../../output-schemas'; + +export const listEmailTemplates = createAction({ + auth: salesforceAuth, + name: 'list_email_templates', + classification: 'SEARCH', + displayName: 'List Email Templates', + description: 'List email templates, optionally filtered by name or folder.', + audience: 'ai', + aiMetadata: { + description: + 'Lists Salesforce email templates (classic and Lightning) with their folder, type, subject and active flag, filtered by name, folder id or active only. Use it to look up a template id or developer name; it does not send anything, use Send Email Message for that. Returns at most 200 templates per call. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listEmailTemplatesOutputSchema, + props: { + name_contains: Property.ShortText({ + displayName: 'Name Contains', + description: 'Only templates whose name contains this text.', + required: false, + }), + folder_id: Property.ShortText({ + displayName: 'Folder ID', + description: 'Only templates in this folder.', + required: false, + }), + active_only: Property.Checkbox({ + displayName: 'Active Only', + required: false, + defaultValue: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum templates to return (1-200, default 50).', + required: false, + }), + }, + async run(context) { + const { name_contains, folder_id, active_only, limit } = context.propsValue; + const conditions = [ + name_contains ? `Name LIKE '%${salesforceUtils.escapeSoqlLike(name_contains)}%'` : undefined, + folder_id + ? `FolderId = '${salesforceUtils.assertId({ value: folder_id, fieldName: 'Folder ID' })}'` + : undefined, + active_only ? 'IsActive = true' : undefined, + ].filter((condition) => condition !== undefined); + const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; + const soql = `SELECT Id, Name, DeveloperName, Subject, FolderId, Folder.Name, TemplateType, IsActive, LastModifiedDate FROM EmailTemplate${where} ORDER BY Name LIMIT ${clampLimit(limit)}`; + const response = await querySalesforceApi>(HttpMethod.GET, context.auth, soql); + return salesforceUtils.formatQueryResult(response.body); + }, +}); + +function clampLimit(limit: number | undefined): number { + return Math.min(Math.max(Math.floor(limit ?? 50), 1), 200); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-invocable-actions.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-invocable-actions.ts new file mode 100644 index 000000000000..70b94e942ff4 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-invocable-actions.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { listInvocableActionsOutputSchema } from '../../output-schemas'; + +export const listInvocableActions = createAction({ + auth: salesforceAuth, + name: 'list_invocable_actions', + classification: 'READ', + displayName: 'List Invocable Actions', + description: 'List the standard or custom invocable actions (flows, Apex, email alerts…) available in the org.', + audience: 'ai', + aiMetadata: { + description: + 'Lists invocable actions you can call with Run Invocable Action. Scope standard returns built-in actions (e.g. emailSimple, postToChatter); scope custom with no type returns the available custom action types (flow, apex, quickAction, emailAlert…), and with a type returns the actions of that type. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listInvocableActionsOutputSchema, + props: { + scope: Property.StaticDropdown({ + displayName: 'Scope', + required: true, + defaultValue: 'standard', + options: { + options: [ + { label: 'Standard', value: 'standard' }, + { label: 'Custom', value: 'custom' }, + ], + }, + }), + custom_type: Property.ShortText({ + displayName: 'Custom Action Type', + description: 'For custom scope: flow, apex, quickAction, emailAlert… Leave empty to list the available types.', + required: false, + }), + }, + async run(context) { + const customType = context.propsValue.custom_type?.trim(); + const path = + context.propsValue.scope === 'custom' + ? `custom${customType ? `/${salesforceUtils.assertApiName({ value: customType, fieldName: 'Custom Action Type' })}` : ''}` + : 'standard'; + const response = await callSalesforceApi(HttpMethod.GET, context.auth, `/services/data/v56.0/actions/${path}`, undefined); + const body: unknown = response.body; + if (salesforceUtils.isRecord(body) && Array.isArray(body['actions'])) { + const actions = body['actions'].filter(salesforceUtils.isRecord).map((action) => ({ + name: action['name'] ?? null, + label: action['label'] ?? null, + type: action['type'] ?? null, + url: action['url'] ?? null, + })); + return { actions, count: actions.length }; + } + const types = salesforceUtils.isRecord(body) + ? Object.entries(body).map(([type, url]) => ({ name: type, label: type, type: 'actionType', url })) + : []; + return { actions: types, count: types.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-list-views.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-list-views.ts new file mode 100644 index 000000000000..a83d4d526957 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-list-views.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { listListViewsOutputSchema } from '../../output-schemas'; + +export const listListViews = createAction({ + auth: salesforceAuth, + name: 'list_list_views', + classification: 'READ', + displayName: 'List List Views', + description: 'List the list views defined for an object.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the list views of one object (e.g. "My Open Opportunities" on Opportunity) with their ids. Pass an id to Get List View Metadata to see its columns and SOQL, or to Get List View Records to read its rows. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listListViewsOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or Opportunity.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const response = await callSalesforceApi(HttpMethod.GET, context.auth, `/services/data/v56.0/sobjects/${object}/listviews`, undefined); + const body: unknown = response.body; + const listViews = (salesforceUtils.isRecord(body) && Array.isArray(body['listviews']) ? body['listviews'] : []) + .filter(salesforceUtils.isRecord) + .map((view) => ({ + id: view['id'] ?? null, + label: view['label'] ?? null, + developer_name: view['developerName'] ?? null, + soql_compatible: view['soqlCompatible'] ?? null, + results_url: view['resultsUrl'] ?? null, + })); + return { list_views: listViews, count: listViews.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-objects.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-objects.ts new file mode 100644 index 000000000000..08894560c8b9 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-objects.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { listObjectsOutputSchema } from '../../output-schemas'; + +export const listObjects = createAction({ + auth: salesforceAuth, + name: 'list_objects', + classification: 'READ', + displayName: 'List Objects', + description: 'List the objects available in the org.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the standard and custom objects in the Salesforce org with their API name, label and what the current user may do with them (query, create, update, delete). Use it to discover the right object API name before calling other tools; for one object\'s fields and relationships use Describe Object. Narrow the list with Search or Custom Only, since orgs often have hundreds of objects. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listObjectsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only return objects whose API name or label contains this text (case-insensitive).', + required: false, + }), + custom_only: Property.Checkbox({ + displayName: 'Custom Only', + description: 'Only return custom objects.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const response = await callSalesforceApi<{ sobjects: SObjectSummary[] }>( + HttpMethod.GET, + context.auth, + '/services/data/v56.0/sobjects', + undefined + ); + const search = (context.propsValue.search ?? '').trim().toLowerCase(); + const objects = response.body.sobjects + .filter((object) => !context.propsValue.custom_only || object.custom) + .filter((object) => search.length === 0 || object.name.toLowerCase().includes(search) || object.label.toLowerCase().includes(search)) + .map((object) => ({ + name: object.name, + label: object.label, + custom: object.custom, + queryable: object.queryable, + createable: object.createable, + updateable: object.updateable, + deletable: object.deletable, + key_prefix: object.keyPrefix, + })); + return { objects, count: objects.length }; + }, +}); + +type SObjectSummary = { + name: string; + label: string; + custom: boolean; + queryable: boolean; + createable: boolean; + updateable: boolean; + deletable: boolean; + keyPrefix: string | null; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-pricebooks.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-pricebooks.ts new file mode 100644 index 000000000000..fd581b6dd4fe --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-pricebooks.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { listPricebooksOutputSchema } from '../../output-schemas'; + +export const listPricebooks = createAction({ + auth: salesforceAuth, + name: 'list_pricebooks', + classification: 'READ', + displayName: 'List Price Books', + description: 'List price books and, optionally, their product entries.', + audience: 'ai', + aiMetadata: { + description: + 'Lists Salesforce price books (id, name, active, standard) and, when Include Entries is on, their price book entries with product name, code and list price, filtered by price book id and product name. Use it to find the Price Book ID for Create Opportunity and the Price Book Entry ID for Add Opportunity Product. Each list returns at most 200 rows (default 50). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listPricebooksOutputSchema, + props: { + pricebook_id: Property.ShortText({ displayName: 'Price Book ID', description: 'Only this price book.', required: false }), + active_only: Property.Checkbox({ displayName: 'Active Only', required: false, defaultValue: false }), + include_entries: Property.Checkbox({ displayName: 'Include Entries', description: 'Also return product entries.', required: false, defaultValue: false }), + product_name: Property.ShortText({ displayName: 'Product Name Contains', description: 'Filters entries.', required: false }), + limit: crmUtils.limitProp, + }, + async run(context) { + const { pricebook_id, active_only, include_entries, product_name, limit } = context.propsValue; + const activeFilter = active_only ? true : undefined; + const pricebooks = await crmUtils.searchRecords({ + auth: context.auth, + object: 'Pricebook2', + fields: ['Id', 'Name', 'IsActive', 'IsStandard', 'Description'], + conditions: [crmUtils.idEquals({ field: 'Id', value: pricebook_id }), crmUtils.booleanEquals({ field: 'IsActive', value: activeFilter })], + orderBy: 'Name ASC', + limit, + }); + if (!include_entries) { + return { pricebooks, entries: null }; + } + const entries = await crmUtils.searchRecords({ + auth: context.auth, + object: 'PricebookEntry', + fields: ['Id', 'Pricebook2Id', 'Product2Id', 'Product2.Name', 'ProductCode', 'UnitPrice', 'IsActive'], + conditions: [ + crmUtils.idEquals({ field: 'Pricebook2Id', value: pricebook_id }), + crmUtils.contains({ field: 'Product2.Name', value: product_name }), + crmUtils.booleanEquals({ field: 'IsActive', value: activeFilter }), + ], + orderBy: 'Product2.Name ASC', + limit, + }); + return { pricebooks, entries }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-quick-actions.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-quick-actions.ts new file mode 100644 index 000000000000..79745088efb2 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-quick-actions.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { listQuickActionsOutputSchema } from '../../output-schemas'; + +export const listQuickActions = createAction({ + auth: salesforceAuth, + name: 'list_quick_actions', + classification: 'READ', + displayName: 'List Quick Actions', + description: 'List global quick actions, or the quick actions of one object.', + audience: 'ai', + aiMetadata: { + description: + 'Lists quick actions (e.g. LogACall, NewTask, custom create/update actions) either globally or for one object. Pass the returned names unchanged to Get Quick Action Defaults and Run Quick Action. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listQuickActionsOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account. Leave empty for global quick actions.', + required: false, + }), + }, + async run(context) { + const object = context.propsValue.object?.trim(); + const path = object + ? `sobjects/${salesforceUtils.assertApiName({ value: object, fieldName: 'Object' })}/quickActions` + : 'quickActions'; + const response = await callSalesforceApi(HttpMethod.GET, context.auth, `/services/data/v56.0/${path}`, undefined); + const body: unknown = response.body; + const actions = (Array.isArray(body) ? body : []).filter(salesforceUtils.isRecord).map((action) => ({ + name: action['name'] ?? null, + label: action['label'] ?? null, + type: action['type'] ?? null, + })); + return { actions, count: actions.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/list-reports.ts b/packages/pieces/community/salesforce/src/lib/action/ai/list-reports.ts new file mode 100644 index 000000000000..8e815db6fc78 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/list-reports.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { querySalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { listReportsOutputSchema } from '../../output-schemas'; + +export const listReports = createAction({ + auth: salesforceAuth, + name: 'list_reports', + classification: 'SEARCH', + displayName: 'List Reports', + description: 'List reports, optionally filtered by name or folder.', + audience: 'ai', + aiMetadata: { + description: + 'Lists Salesforce analytics reports (id, name, folder, format, last run) filtered by name or exact folder name. Use it to find a report id, then Get Report to see its columns or Run Report Sync / Run Report Async to get data. Returns at most 200 reports per call. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: listReportsOutputSchema, + props: { + name_contains: Property.ShortText({ + displayName: 'Name Contains', + description: 'Only reports whose name contains this text.', + required: false, + }), + folder_name: Property.ShortText({ + displayName: 'Folder Name', + description: 'Only reports in the folder with exactly this name.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum reports to return (1-200, default 50).', + required: false, + }), + }, + async run(context) { + const { name_contains, folder_name, limit } = context.propsValue; + const conditions = [ + name_contains ? `Name LIKE '%${salesforceUtils.escapeSoqlLike(name_contains)}%'` : undefined, + folder_name ? `FolderName = '${salesforceUtils.escapeSoqlValue(folder_name)}'` : undefined, + ].filter((condition) => condition !== undefined); + const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; + const soql = `SELECT Id, Name, DeveloperName, FolderName, Format, Description, LastRunDate, LastModifiedDate FROM Report${where} ORDER BY Name LIMIT ${clampLimit(limit)}`; + const response = await querySalesforceApi>(HttpMethod.GET, context.auth, soql); + return salesforceUtils.formatQueryResult(response.body); + }, +}); + +function clampLimit(limit: number | undefined): number { + return Math.min(Math.max(Math.floor(limit ?? 50), 1), 200); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/log-call.ts b/packages/pieces/community/salesforce/src/lib/action/ai/log-call.ts new file mode 100644 index 000000000000..b7b7adbc2c92 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/log-call.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { logCallOutputSchema } from '../../output-schemas'; + +export const logCall = createAction({ + auth: salesforceAuth, + name: 'log_call', + classification: 'WRITE', + displayName: 'Log Call', + description: 'Record a completed phone call as an activity.', + audience: 'ai', + aiMetadata: { + description: + "Logs a phone call that already happened as a closed Call task on a Contact or Lead (Who ID) and/or an Account, Opportunity or other record (What ID), with duration, direction, outcome and notes; the date defaults to today (UTC). Use Create Task for a to-do that is still open. Not idempotent: each call logs another activity.", + idempotent: false, + }, + outputSchema: logCallOutputSchema, + props: { + subject: Property.ShortText({ displayName: 'Subject', description: 'e.g. Call with Jane about renewal.', required: true }), + who_id: Property.ShortText({ displayName: 'Who ID', description: 'Contact or Lead id.', required: false }), + what_id: Property.ShortText({ displayName: 'What ID', description: 'Account, Opportunity, Case or other record id.', required: false }), + call_type: Property.StaticDropdown({ + displayName: 'Call Type', + required: false, + options: { + options: [ + { label: 'Inbound', value: 'Inbound' }, + { label: 'Outbound', value: 'Outbound' }, + { label: 'Internal', value: 'Internal' }, + ], + }, + }), + duration_seconds: Property.Number({ displayName: 'Duration (Seconds)', required: false }), + call_disposition: Property.ShortText({ displayName: 'Call Result', description: 'Outcome, e.g. Left voicemail.', required: false }), + description: Property.LongText({ displayName: 'Notes', required: false }), + activity_date: Property.ShortText({ displayName: 'Call Date', description: 'YYYY-MM-DD. Defaults to today.', required: false }), + owner_id: Property.ShortText({ displayName: 'Owner ID', description: 'User who made the call. Defaults to you.', required: false }), + }, + async run(context) { + const { subject, who_id, what_id, call_type, duration_seconds, call_disposition, description, activity_date, owner_id } = context.propsValue; + const activityDate = crmUtils.assertDate({ value: activity_date, fieldName: 'Call Date' }) ?? new Date().toISOString().slice(0, 10); + const status = await crmUtils.getClosedTaskStatus({ auth: context.auth }); + const result = await crmUtils.createRecord({ + auth: context.auth, + object: 'Task', + fields: { + TaskSubtype: 'Call', + Status: status, + Subject: subject, + WhoId: who_id, + WhatId: what_id, + CallType: call_type, + CallDurationInSeconds: duration_seconds, + CallDisposition: call_disposition, + Description: description, + ActivityDate: activityDate, + OwnerId: owner_id, + }, + additionalFields: undefined, + }); + return { ...result, subject, status, activity_date: activityDate }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/log-email-activity.ts b/packages/pieces/community/salesforce/src/lib/action/ai/log-email-activity.ts new file mode 100644 index 000000000000..942d5b03d5ec --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/log-email-activity.ts @@ -0,0 +1,68 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { logEmailActivityOutputSchema } from '../../output-schemas'; + +export const logEmailActivity = createAction({ + auth: salesforceAuth, + name: 'log_email_activity', + classification: 'WRITE', + displayName: 'Log Email Activity', + description: 'Record an email on a Contact or Lead without sending it.', + audience: 'ai', + aiMetadata: { + description: + 'Creates an EmailMessage record marked as sent for a Contact or Lead, optionally related to another record, so the email shows in the activity history. It only records the email and does NOT deliver anything; to actually send an email use Send Email Message. Not idempotent: every call logs another email.', + idempotent: false, + }, + outputSchema: logEmailActivityOutputSchema, + props: { + recipient_id: Property.ShortText({ + displayName: 'Recipient ID', + description: 'Id of the Contact or Lead the email was sent to.', + required: true, + }), + subject: Property.ShortText({ + displayName: 'Subject', + required: true, + }), + body: Property.LongText({ + displayName: 'Body', + description: 'Email content, plain text or HTML.', + required: true, + }), + related_record_id: Property.ShortText({ + displayName: 'Related Record ID', + description: 'Id of a record (Account, Opportunity, Case…) to relate the email to.', + required: false, + }), + }, + async run(context) { + const { subject, body } = context.propsValue; + const recipientId = salesforceUtils.assertId({ value: context.propsValue.recipient_id, fieldName: 'Recipient ID' }); + const relatedRecordId = context.propsValue.related_record_id + ? salesforceUtils.assertId({ value: context.propsValue.related_record_id, fieldName: 'Related Record ID' }) + : undefined; + const response = await callSalesforceApi<{ id: string; success: boolean }>( + HttpMethod.POST, + context.auth, + '/services/data/v56.0/sobjects/EmailMessage', + salesforceUtils.compact({ + ToIds: [recipientId], + Subject: subject, + HtmlBody: body, + Status: '3', + RelatedToId: relatedRecordId, + }), + ); + return { + id: response.body.id, + success: response.body.success, + recipient_id: recipientId, + related_record_id: relatedRecordId ?? null, + subject, + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/remove-campaign-member.ts b/packages/pieces/community/salesforce/src/lib/action/ai/remove-campaign-member.ts new file mode 100644 index 000000000000..20465c88b64d --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/remove-campaign-member.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { crmUtils } from '../../common/crm'; +import { salesforceUtils } from '../../common/utils'; +import { deletedRecordOutputSchema } from '../../output-schemas'; + +export const removeCampaignMember = createAction({ + auth: salesforceAuth, + name: 'remove_campaign_member', + classification: 'DESTRUCTIVE', + displayName: 'Remove Campaign Member', + description: 'Remove a lead or contact from a campaign.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a Lead or Contact from a Campaign by deleting its CampaignMember record; give the campaign id and exactly one of lead id or contact id. The lead or contact itself is not deleted. Use Add Lead to Campaign / Add Contact to Campaign to enroll again. Reports deleted: false when it is not a member. Not idempotent: it deletes a record.', + idempotent: false, + }, + outputSchema: deletedRecordOutputSchema, + props: { + campaign_id: Property.ShortText({ displayName: 'Campaign ID', required: true }), + lead_id: Property.ShortText({ displayName: 'Lead ID', description: 'Give this or Contact ID.', required: false }), + contact_id: Property.ShortText({ displayName: 'Contact ID', description: 'Give this or Lead ID.', required: false }), + }, + async run(context) { + const { campaign_id, lead_id, contact_id } = context.propsValue; + const memberIndex = crmUtils.exactlyOne({ values: [lead_id, contact_id], fieldNames: 'Lead ID or Contact ID' }); + const campaignId = salesforceUtils.assertId({ value: campaign_id, fieldName: 'Campaign ID' }); + const memberId = salesforceUtils.assertId({ value: (memberIndex === 0 ? lead_id : contact_id) ?? '', fieldName: memberIndex === 0 ? 'Lead ID' : 'Contact ID' }); + const memberField = memberIndex === 0 ? 'LeadId' : 'ContactId'; + const result = await crmUtils.query({ + auth: context.auth, + soql: `SELECT Id FROM CampaignMember WHERE CampaignId = '${campaignId}' AND ${memberField} = '${memberId}' LIMIT 1`, + }); + const member = result.records[0]; + if (!salesforceUtils.isRecord(member) || typeof member['Id'] !== 'string') { + return { deleted: false, reason: 'Not a member of this campaign.' }; + } + const id = salesforceUtils.assertId({ value: member['Id'], fieldName: 'Campaign Member ID' }); + await callSalesforceApi(HttpMethod.DELETE, context.auth, `/services/data/v56.0/sobjects/CampaignMember/${id}`, undefined); + return { id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-bulk-ingest-job.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-bulk-ingest-job.ts new file mode 100644 index 000000000000..6b65944e21dd --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-bulk-ingest-job.ts @@ -0,0 +1,90 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { bulkUtils } from '../../common/bulk'; +import { bulkJobCreatedOutputSchema } from '../../output-schemas'; + +export const runBulkIngestJob = createAction({ + auth: salesforceAuth, + name: 'run_bulk_ingest_job', + classification: 'WRITE', + displayName: 'Run Bulk Ingest Job', + description: 'Start a Bulk API 2.0 job that inserts, updates, upserts or deletes records from CSV.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a Bulk API 2.0 ingest job, uploads the CSV and queues it for processing in one call, returning the job id right away while Salesforce works asynchronously. Poll Get Bulk Ingest Job until the state is JobComplete, Failed or Aborted, then read per-row outcomes with Get Bulk Ingest Results. Use it for thousands of rows; the CSV header must be field API names (Id for update/delete), and upsert needs an external id field. Not idempotent: rerunning inserts again.', + idempotent: false, + }, + outputSchema: bulkJobCreatedOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or My_Object__c.', + required: true, + }), + operation: Property.StaticDropdown({ + displayName: 'Operation', + required: true, + options: { + options: [ + { label: 'Insert', value: 'insert' }, + { label: 'Update', value: 'update' }, + { label: 'Upsert', value: 'upsert' }, + { label: 'Delete', value: 'delete' }, + ], + }, + }), + external_id_field: Property.ShortText({ + displayName: 'External ID Field', + description: 'Field API name to match on. Required for upsert.', + required: false, + }), + csv: Property.LongText({ + displayName: 'CSV', + description: 'Records as CSV with a header row of field API names, lines separated by LF.', + required: true, + }), + }, + async run(context) { + const { operation, csv } = context.propsValue; + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const externalIdField = context.propsValue.external_id_field?.trim() + ? salesforceUtils.assertApiName({ value: context.propsValue.external_id_field, fieldName: 'External ID Field' }) + : undefined; + if (operation === 'upsert' && !externalIdField) { + throw new Error('External ID Field is required for upsert.'); + } + if (!csv.trim()) { + throw new Error('CSV must not be empty.'); + } + const created = await callSalesforceApi<{ id: string }>(HttpMethod.POST, context.auth, `${bulkUtils.BULK_API_PATH}/ingest`, { + object, + operation, + contentType: 'CSV', + lineEnding: 'LF', + ...(externalIdField ? { externalIdFieldName: externalIdField } : {}), + }); + const jobId = created.body.id; + const jobPath = `${bulkUtils.BULK_API_PATH}/ingest/${jobId}`; + try { + await callSalesforceApi(HttpMethod.PUT, context.auth, `${jobPath}/batches`, csv.replace(/\r\n/g, '\n'), { + headers: { 'Content-Type': 'text/csv' }, + }); + } catch (error) { + await callSalesforceApi(HttpMethod.PATCH, context.auth, jobPath, { state: 'Aborted' }).catch(() => undefined); + throw new Error(`CSV upload failed, job ${jobId} was aborted: ${error instanceof Error ? error.message : String(error)}`); + } + const closed = await callSalesforceApi<{ state?: string }>(HttpMethod.PATCH, context.auth, jobPath, { + state: 'UploadComplete', + }); + return { + job_id: jobId, + state: closed.body?.state ?? 'UploadComplete', + object, + operation, + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-invocable-action.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-invocable-action.ts new file mode 100644 index 000000000000..3e3f05c6491a --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-invocable-action.ts @@ -0,0 +1,81 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { runInvocableActionOutputSchema } from '../../output-schemas'; + +export const runInvocableAction = createAction({ + auth: salesforceAuth, + name: 'run_invocable_action', + classification: 'WRITE', + displayName: 'Run Invocable Action', + description: 'Run a standard or custom invocable action, such as an autolaunched flow or Apex action.', + audience: 'ai', + aiMetadata: { + description: + 'Invokes a standard action (e.g. emailSimple) or a custom action (flow, apex, emailAlert…) with one or more input sets and returns each invocation\'s success, errors and output values. Find names and types with List Invocable Actions; flows must be active and autolaunched, and for Apex the name is the class name. Not idempotent: each call runs the action again.', + idempotent: false, + }, + outputSchema: runInvocableActionOutputSchema, + props: { + action_type: Property.ShortText({ + displayName: 'Custom Action Type', + description: 'flow, apex, quickAction, emailAlert… Leave empty for a standard action.', + required: false, + }), + action_name: Property.ShortText({ + displayName: 'Action Name', + description: 'Action API name, e.g. emailSimple or My_Autolaunched_Flow.', + required: true, + }), + inputs: Property.Json({ + displayName: 'Inputs', + description: 'JSON array of input objects, one per invocation (a single object is also accepted), e.g. [{"recordId": "001..."}].', + required: false, + }), + }, + async run(context) { + const actionName = salesforceUtils.assertApiName({ value: context.propsValue.action_name, fieldName: 'Action Name' }); + const rawType = context.propsValue.action_type?.trim(); + const actionType = rawType && rawType.toLowerCase() !== 'standard' ? rawType : undefined; + const path = actionType + ? `custom/${salesforceUtils.assertApiName({ value: actionType, fieldName: 'Custom Action Type' })}/${actionName}` + : `standard/${actionName}`; + const inputs = parseInputs(context.propsValue.inputs); + const response = await callSalesforceApi(HttpMethod.POST, context.auth, `/services/data/v56.0/actions/${path}`, { inputs }); + const body: unknown = response.body; + const results = (Array.isArray(body) ? body : []).filter(salesforceUtils.isRecord).map((result) => ({ + action_name: result['actionName'] ?? actionName, + is_success: result['isSuccess'] === true, + errors: formatErrors(result['errors']), + output_values: result['outputValues'] ?? null, + })); + const failures = results.filter((result) => !result.is_success); + if (results.length > 0 && failures.length === results.length) { + throw new Error(`Action ${actionName} failed: ${failures.map((failure) => failure.errors.join('; ')).join(' | ')}`); + } + return { results, count: results.length, failed_count: failures.length }; + }, +}); + +function parseInputs(value: unknown): unknown[] { + if (salesforceUtils.isRecord(value)) { + return [value]; + } + if (typeof value === 'string' && value.trim().startsWith('{')) { + const single = salesforceUtils.parseJsonObject({ value, fieldName: 'Inputs' }); + return single ? [single] : [{}]; + } + const list = salesforceUtils.parseJsonArray({ value, fieldName: 'Inputs' }); + return list && list.length > 0 ? list : [{}]; +} + +function formatErrors(errors: unknown): string[] { + if (!Array.isArray(errors)) { + return []; + } + return errors.map((error) => + salesforceUtils.isRecord(error) ? String(error['message'] ?? error['statusCode'] ?? JSON.stringify(error)) : String(error) + ); +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-quick-action.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-quick-action.ts new file mode 100644 index 000000000000..650a8e94d1f2 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-quick-action.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { runQuickActionOutputSchema } from '../../output-schemas'; + +export const runQuickAction = createAction({ + auth: salesforceAuth, + name: 'run_quick_action', + classification: 'WRITE', + displayName: 'Run Quick Action', + description: 'Run a global or object-specific quick action with the given field values.', + audience: 'ai', + aiMetadata: { + description: + 'Executes a quick action (e.g. LogACall, NewContact) and returns the id of the record it created or updated. Find names with List Quick Actions and pre-filled fields with Get Quick Action Defaults; pass the name exactly as listed (Object.Action or a bare global name) and a Context Record ID when the action acts on a parent record. Not idempotent: each call creates again.', + idempotent: false, + }, + outputSchema: runQuickActionOutputSchema, + props: { + action_name: Property.ShortText({ + displayName: 'Quick Action Name', + description: 'Name exactly as returned by List Quick Actions: Object.Action for object-specific actions (e.g. Account.NewContact), a bare name for global ones (e.g. LogACall).', + required: true, + }), + context_id: Property.ShortText({ + displayName: 'Context Record ID', + description: 'Id of the parent record the action runs against. Optional.', + required: false, + }), + record: Property.Json({ + displayName: 'Fields', + description: 'JSON object of field API names to values, e.g. {"Subject": "Call", "Description": "Notes"}.', + required: true, + }), + }, + async run(context) { + const actionName = context.propsValue.action_name + .trim() + .split('.') + .map((part) => salesforceUtils.assertApiName({ value: part, fieldName: 'Quick Action Name' })) + .join('.'); + const contextId = context.propsValue.context_id?.trim() + ? salesforceUtils.assertId({ value: context.propsValue.context_id, fieldName: 'Context Record ID' }) + : undefined; + const record = salesforceUtils.parseJsonObject({ value: context.propsValue.record, fieldName: 'Fields' }) ?? {}; + const [objectName] = actionName.split('.'); + const path = actionName.includes('.') ? `sobjects/${objectName}/quickActions/${actionName}` : `quickActions/${actionName}`; + const response = await callSalesforceApi(HttpMethod.POST, context.auth, `/services/data/v56.0/${path}`, { + ...(contextId ? { contextId } : {}), + record, + }); + const body: unknown = response.body; + const result = salesforceUtils.isRecord(body) ? body : {}; + return { + id: result['id'] ?? null, + success: result['success'] ?? true, + created: result['created'] ?? null, + context_id: result['contextId'] ?? contextId ?? null, + feed_item_ids: result['feedItemIds'] ?? null, + errors: result['errors'] ?? [], + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-report-async.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-report-async.ts new file mode 100644 index 000000000000..27b1be5d47da --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-report-async.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { runReportAsyncOutputSchema } from '../../output-schemas'; + +export const runReportAsync = createAction({ + auth: salesforceAuth, + name: 'run_report_async', + classification: 'READ', + displayName: 'Run Report Async', + description: 'Start a report run in the background.', + audience: 'ai', + aiMetadata: { + description: + 'Starts an asynchronous run of a Salesforce report by id, optionally with filters overriding the saved ones, and returns the instance id right away. Poll Get Report Instance with the report id and instance id until the status is Success to get the rows; use it instead of Run Report Sync for reports over 2,000 detail rows. Results are kept for 24 hours. Not idempotent: every call starts another run.', + idempotent: false, + }, + outputSchema: runReportAsyncOutputSchema, + props: { + report_id: Property.ShortText({ + displayName: 'Report ID', + description: 'Report id starting with 00O.', + required: true, + }), + filters: Property.Json({ + displayName: 'Filters', + description: + 'Optional JSON array of {column, operator, value} using column API names from Get Report, e.g. [{"column":"ACCOUNT.NAME","operator":"equals","value":"Acme"}].', + required: false, + }), + }, + async run(context) { + const reportId = salesforceUtils.assertId({ value: context.propsValue.report_id, fieldName: 'Report ID' }); + const filters = salesforceUtils.parseJsonArray({ value: context.propsValue.filters, fieldName: 'Filters' }); + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + `/services/data/v56.0/analytics/reports/${reportId}/instances?includeDetails=true`, + filters && filters.length > 0 ? { reportMetadata: { reportFilters: filters } } : undefined, + ); + return { + instance_id: response.body.id, + status: response.body.status, + report_id: reportId, + request_date: response.body.requestDate ?? null, + }; + }, +}); + +type ReportInstance = { + id: string; + status: string; + requestDate?: string; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-report-sync.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-report-sync.ts new file mode 100644 index 000000000000..004a4a608976 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-report-sync.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { reportUtils, SalesforceReportResponse } from '../../common/report'; +import { salesforceUtils } from '../../common/utils'; +import { runReportSyncOutputSchema } from '../../output-schemas'; + +export const runReportSync = createAction({ + auth: salesforceAuth, + name: 'run_report_sync', + classification: 'READ', + displayName: 'Run Report Sync', + description: 'Run a report now and return its rows.', + audience: 'ai', + aiMetadata: { + description: + 'Runs a Salesforce report by id and waits for the result, returning flat rows keyed by column label (grouping columns included), optionally with filters that override the saved ones for this run. Synchronous runs return at most 2,000 detail rows; for bigger reports use Run Report Async then Get Report Instance. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: runReportSyncOutputSchema, + props: { + report_id: Property.ShortText({ + displayName: 'Report ID', + description: 'Report id starting with 00O.', + required: true, + }), + filters: Property.Json({ + displayName: 'Filters', + description: + 'Optional JSON array of {column, operator, value} using column API names from Get Report, e.g. [{"column":"ACCOUNT.NAME","operator":"equals","value":"Acme"}].', + required: false, + }), + }, + async run(context) { + const reportId = salesforceUtils.assertId({ value: context.propsValue.report_id, fieldName: 'Report ID' }); + const filters = salesforceUtils.parseJsonArray({ value: context.propsValue.filters, fieldName: 'Filters' }); + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + `/services/data/v56.0/analytics/reports/${reportId}?includeDetails=true`, + filters && filters.length > 0 ? { reportMetadata: { reportFilters: filters } } : undefined, + ); + return reportUtils.transformReportToRows(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query-all.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query-all.ts new file mode 100644 index 000000000000..c5a09f606f32 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query-all.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { soqlQueryOutputSchema } from '../../output-schemas'; + +export const runSoqlQueryAll = createAction({ + auth: salesforceAuth, + name: 'run_soql_query_all', + classification: 'SEARCH', + displayName: 'Run SOQL Query All', + description: 'Run a SOQL query that also returns deleted and archived records.', + audience: 'ai', + aiMetadata: { + description: + 'Runs a SOQL query that also returns records in the Recycle Bin and archived records (e.g. old Tasks and Events); filter on IsDeleted = true to find only deleted ones. Use it for recovery or audit; for normal reads use Run SOQL Query. Always add a LIMIT; if next_records_url is not null, fetch more with Get Next Query Page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: soqlQueryOutputSchema, + props: { + query: Property.LongText({ + displayName: 'SOQL Query', + description: 'A complete SOQL statement with explicit fields and a LIMIT.', + required: true, + }), + }, + async run(context) { + const query = context.propsValue.query.trim(); + if (query.length === 0) { + throw new Error('SOQL Query is required.'); + } + const response = await callSalesforceApi>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/queryAll?q=${encodeURIComponent(query)}`, + undefined + ); + return salesforceUtils.formatQueryResult(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query.ts new file mode 100644 index 000000000000..beda66eb6e58 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-soql-query.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { querySalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { soqlQueryOutputSchema } from '../../output-schemas'; + +export const runSoqlQuery = createAction({ + auth: salesforceAuth, + name: 'run_soql_query', + classification: 'SEARCH', + displayName: 'Run SOQL Query', + description: 'Run a SOQL query and return the matching records.', + audience: 'ai', + aiMetadata: { + description: + 'Runs a SOQL query (e.g. SELECT Id, Name FROM Account WHERE Industry = \'Technology\' LIMIT 50) and returns the matching records, excluding deleted and archived ones. Use it for filtered, sorted, related or aggregate reads; to include the Recycle Bin use Run SOQL Query All, for keyword search across objects use Search Records (SOSL), and for setup metadata use Run Tooling Query. Always add a LIMIT; if next_records_url is not null, fetch more with Get Next Query Page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: soqlQueryOutputSchema, + props: { + query: Property.LongText({ + displayName: 'SOQL Query', + description: 'A complete SOQL statement with explicit fields and a LIMIT.', + required: true, + }), + }, + async run(context) { + const query = context.propsValue.query.trim(); + if (query.length === 0) { + throw new Error('SOQL Query is required.'); + } + const response = await querySalesforceApi>(HttpMethod.GET, context.auth, query); + return salesforceUtils.formatQueryResult(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/run-tooling-query.ts b/packages/pieces/community/salesforce/src/lib/action/ai/run-tooling-query.ts new file mode 100644 index 000000000000..7cb2462715be --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/run-tooling-query.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { soqlQueryOutputSchema } from '../../output-schemas'; + +export const runToolingQuery = createAction({ + auth: salesforceAuth, + name: 'run_tooling_query', + classification: 'SEARCH', + displayName: 'Run Tooling Query', + description: 'Run a SOQL query against the Tooling API for setup metadata.', + audience: 'ai', + aiMetadata: { + description: + 'Runs a SOQL query against the Tooling API to read setup metadata such as ApexClass, ApexTrigger, CustomField, Flow, ValidationRule or FieldDefinition. Use it for questions about org configuration and code; for business records use Run SOQL Query, and for a single object\'s fields Describe Object is simpler. Requires a user with View Setup permission; always add a LIMIT and page with Get Next Query Page. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: soqlQueryOutputSchema, + props: { + query: Property.LongText({ + displayName: 'SOQL Query', + description: 'A Tooling API SOQL statement, e.g. SELECT Id, Name FROM ApexClass LIMIT 50.', + required: true, + }), + }, + async run(context) { + const query = context.propsValue.query.trim(); + if (query.length === 0) { + throw new Error('SOQL Query is required.'); + } + const response = await callSalesforceApi>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/tooling/query?q=${encodeURIComponent(query)}`, + undefined + ); + return salesforceUtils.formatQueryResult(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-accounts.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-accounts.ts new file mode 100644 index 000000000000..21fd2f747f53 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-accounts.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { searchAccountsOutputSchema } from '../../output-schemas'; + +export const searchAccounts = createAction({ + auth: salesforceAuth, + name: 'search_accounts', + classification: 'SEARCH', + displayName: 'Search Accounts', + description: 'Find accounts by name, industry, type, location or owner.', + audience: 'ai', + aiMetadata: { + description: + 'Finds Salesforce Accounts matching all the filters you give (name contains, exact industry, type, billing city/country, owner id), newest-modified first; with no filters it lists recent accounts. Use it to get an account id before Update Account or to link contacts and opportunities; for arbitrary conditions use a SOQL query action instead. Returns at most 200 rows (default 50). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchAccountsOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name Contains', required: false }), + industry: Property.ShortText({ displayName: 'Industry', required: false }), + type: Property.ShortText({ displayName: 'Type', required: false }), + billing_city: Property.ShortText({ displayName: 'Billing City', required: false }), + billing_country: Property.ShortText({ displayName: 'Billing Country', required: false }), + owner_id: Property.ShortText({ displayName: 'Owner ID', required: false }), + limit: crmUtils.limitProp, + }, + async run(context) { + const { name, industry, type, billing_city, billing_country, owner_id, limit } = context.propsValue; + return crmUtils.searchRecords({ + auth: context.auth, + object: 'Account', + fields: ['Id', 'Name', 'Phone', 'Website', 'Industry', 'Type', 'BillingCity', 'BillingState', 'BillingCountry', 'OwnerId', 'ParentId', 'CreatedDate', 'LastModifiedDate'], + conditions: [ + crmUtils.contains({ field: 'Name', value: name }), + crmUtils.equals({ field: 'Industry', value: industry }), + crmUtils.equals({ field: 'Type', value: type }), + crmUtils.equals({ field: 'BillingCity', value: billing_city }), + crmUtils.equals({ field: 'BillingCountry', value: billing_country }), + crmUtils.idEquals({ field: 'OwnerId', value: owner_id }), + ], + orderBy: 'LastModifiedDate DESC', + limit, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-contacts.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-contacts.ts new file mode 100644 index 000000000000..465edfec45c3 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-contacts.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { searchContactsOutputSchema } from '../../output-schemas'; + +export const searchContacts = createAction({ + auth: salesforceAuth, + name: 'search_contacts', + classification: 'SEARCH', + displayName: 'Search Contacts', + description: 'Find contacts by name, email, account or title.', + audience: 'ai', + aiMetadata: { + description: + 'Finds Salesforce Contacts matching all the filters you give (name contains, exact email, account id, title contains), newest-modified first; with no filters it lists recent contacts. Use it to get a contact id before Update Contact, Add Contact to Campaign or Create Task; for leads use Search Leads. Returns at most 200 rows (default 50). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchContactsOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name Contains', required: false }), + email: Property.ShortText({ displayName: 'Email', description: 'Exact email address.', required: false }), + account_id: Property.ShortText({ displayName: 'Account ID', required: false }), + title: Property.ShortText({ displayName: 'Title Contains', required: false }), + limit: crmUtils.limitProp, + }, + async run(context) { + const { name, email, account_id, title, limit } = context.propsValue; + return crmUtils.searchRecords({ + auth: context.auth, + object: 'Contact', + fields: ['Id', 'FirstName', 'LastName', 'Name', 'Email', 'Phone', 'MobilePhone', 'Title', 'Department', 'AccountId', 'Account.Name', 'OwnerId', 'CreatedDate', 'LastModifiedDate'], + conditions: [ + crmUtils.contains({ field: 'Name', value: name }), + crmUtils.equals({ field: 'Email', value: email }), + crmUtils.idEquals({ field: 'AccountId', value: account_id }), + crmUtils.contains({ field: 'Title', value: title }), + ], + orderBy: 'LastModifiedDate DESC', + limit, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-knowledge-articles.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-knowledge-articles.ts new file mode 100644 index 000000000000..f74444d04a24 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-knowledge-articles.ts @@ -0,0 +1,87 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { searchKnowledgeArticlesOutputSchema } from '../../output-schemas'; + +export const searchKnowledgeArticles = createAction({ + auth: salesforceAuth, + name: 'search_knowledge_articles', + classification: 'SEARCH', + displayName: 'Search Knowledge Articles', + description: 'Search published Salesforce Knowledge articles by keyword.', + audience: 'ai', + aiMetadata: { + description: + 'Searches published Salesforce Knowledge articles by keyword in one language and returns titles, summaries and ids. Use it to answer support questions from the knowledge base; the id is the KnowledgeArticle id, not an article version id. Requires Knowledge enabled and a Knowledge User license on the connected user; page size defaults to 20, max 100. Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchKnowledgeArticlesOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Search Text', + required: true, + }), + language: Property.ShortText({ + displayName: 'Language', + description: 'Article language locale, e.g. en-US.', + required: false, + defaultValue: 'en-US', + }), + channel: Property.StaticDropdown({ + displayName: 'Channel', + required: false, + options: { + options: [ + { label: 'Internal App', value: 'App' }, + { label: 'Public Knowledge Base', value: 'Pkb' }, + { label: 'Customer', value: 'Csp' }, + { label: 'Partner', value: 'Prm' }, + ], + }, + }), + page_size: Property.Number({ + displayName: 'Page Size', + description: 'Articles to return (default 20, max 100).', + required: false, + defaultValue: 20, + }), + }, + async run(context) { + const query = context.propsValue.query.trim(); + if (!query) { + throw new Error('Search Text must not be empty.'); + } + const language = context.propsValue.language?.trim() || 'en-US'; + if (!/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/.test(language)) { + throw new Error('Language must be a locale such as en-US.'); + } + const pageSize = Math.min(Math.max(Math.floor(context.propsValue.page_size ?? 20), 1), 100); + const params = new URLSearchParams({ + q: query, + pageSize: String(pageSize), + ...(context.propsValue.channel ? { channel: context.propsValue.channel } : {}), + }); + const response = await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/support/knowledgeArticles?${params.toString()}`, + undefined, + { headers: { 'Accept-Language': language } } + ); + const body: unknown = response.body; + const articles = (salesforceUtils.isRecord(body) && Array.isArray(body['articles']) ? body['articles'] : []) + .filter(salesforceUtils.isRecord) + .map((article) => ({ + id: article['id'] ?? null, + article_number: article['articleNumber'] ?? null, + title: article['title'] ?? null, + summary: article['summary'] ?? null, + url_name: article['urlName'] ?? null, + last_published_date: article['lastPublishedDate'] ?? null, + view_count: article['viewCount'] ?? null, + })); + return { articles, count: articles.length }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-leads.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-leads.ts new file mode 100644 index 000000000000..9868f9855e70 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-leads.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { searchLeadsOutputSchema } from '../../output-schemas'; + +export const searchLeads = createAction({ + auth: salesforceAuth, + name: 'search_leads', + classification: 'SEARCH', + displayName: 'Search Leads', + description: 'Find leads by name, email, company, status, source or conversion.', + audience: 'ai', + aiMetadata: { + description: + 'Finds Salesforce Leads matching all the filters you give (name contains, exact email, company contains, status, lead source, converted yes/no), newest-modified first; with no filters it lists recent leads. Use it to get a lead id before Update Lead or Add Lead to Campaign; for contacts use Search Contacts. Returns at most 200 rows (default 50). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchLeadsOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name Contains', required: false }), + email: Property.ShortText({ displayName: 'Email', description: 'Exact email address.', required: false }), + company: Property.ShortText({ displayName: 'Company Contains', required: false }), + status: Property.ShortText({ displayName: 'Status', required: false }), + lead_source: Property.ShortText({ displayName: 'Lead Source', required: false }), + is_converted: crmUtils.booleanFilterProp({ displayName: 'Converted', description: 'Leave empty for both converted and unconverted leads.' }), + limit: crmUtils.limitProp, + }, + async run(context) { + const { name, email, company, status, lead_source, is_converted, limit } = context.propsValue; + return crmUtils.searchRecords({ + auth: context.auth, + object: 'Lead', + fields: ['Id', 'FirstName', 'LastName', 'Name', 'Company', 'Email', 'Phone', 'Title', 'Status', 'LeadSource', 'Industry', 'IsConverted', 'ConvertedContactId', 'ConvertedAccountId', 'OwnerId', 'CreatedDate', 'LastModifiedDate'], + conditions: [ + crmUtils.contains({ field: 'Name', value: name }), + crmUtils.equals({ field: 'Email', value: email }), + crmUtils.contains({ field: 'Company', value: company }), + crmUtils.equals({ field: 'Status', value: status }), + crmUtils.equals({ field: 'LeadSource', value: lead_source }), + crmUtils.booleanEquals({ field: 'IsConverted', value: is_converted }), + ], + orderBy: 'LastModifiedDate DESC', + limit, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-opportunities.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-opportunities.ts new file mode 100644 index 000000000000..16426770a00f --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-opportunities.ts @@ -0,0 +1,50 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { searchOpportunitiesOutputSchema } from '../../output-schemas'; + +export const searchOpportunities = createAction({ + auth: salesforceAuth, + name: 'search_opportunities', + classification: 'SEARCH', + displayName: 'Search Opportunities', + description: 'Find opportunities by name, account, stage, status, close date or amount.', + audience: 'ai', + aiMetadata: { + description: + 'Finds Salesforce Opportunities matching all the filters you give (name contains, account id, exact stage, closed / won yes-no, close date range in YYYY-MM-DD, minimum amount), newest-modified first; with no filters it lists recent opportunities. Use it to get an opportunity id before Update Opportunity or Add Opportunity Product, or to review a pipeline. Returns at most 200 rows (default 50). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchOpportunitiesOutputSchema, + props: { + name: Property.ShortText({ displayName: 'Name Contains', required: false }), + account_id: Property.ShortText({ displayName: 'Account ID', required: false }), + stage: Property.ShortText({ displayName: 'Stage', required: false }), + is_closed: crmUtils.booleanFilterProp({ displayName: 'Closed', description: 'Leave empty for open and closed.' }), + is_won: crmUtils.booleanFilterProp({ displayName: 'Won', description: 'Leave empty for won and not won.' }), + close_date_from: Property.ShortText({ displayName: 'Close Date From', description: 'YYYY-MM-DD, inclusive.', required: false }), + close_date_to: Property.ShortText({ displayName: 'Close Date To', description: 'YYYY-MM-DD, inclusive.', required: false }), + min_amount: Property.Number({ displayName: 'Minimum Amount', required: false }), + limit: crmUtils.limitProp, + }, + async run(context) { + const { name, account_id, stage, is_closed, is_won, close_date_from, close_date_to, min_amount, limit } = context.propsValue; + return crmUtils.searchRecords({ + auth: context.auth, + object: 'Opportunity', + fields: ['Id', 'Name', 'AccountId', 'Account.Name', 'StageName', 'Amount', 'Probability', 'CloseDate', 'IsClosed', 'IsWon', 'Type', 'LeadSource', 'NextStep', 'Pricebook2Id', 'OwnerId', 'CreatedDate', 'LastModifiedDate'], + conditions: [ + crmUtils.contains({ field: 'Name', value: name }), + crmUtils.idEquals({ field: 'AccountId', value: account_id }), + crmUtils.equals({ field: 'StageName', value: stage }), + crmUtils.booleanEquals({ field: 'IsClosed', value: is_closed }), + crmUtils.booleanEquals({ field: 'IsWon', value: is_won }), + crmUtils.dateCompare({ field: 'CloseDate', operator: '>=', value: close_date_from }), + crmUtils.dateCompare({ field: 'CloseDate', operator: '<=', value: close_date_to }), + crmUtils.numberCompare({ field: 'Amount', operator: '>=', value: min_amount }), + ], + orderBy: 'LastModifiedDate DESC', + limit, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/search-records-sosl.ts b/packages/pieces/community/salesforce/src/lib/action/ai/search-records-sosl.ts new file mode 100644 index 000000000000..2166fdec3606 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/search-records-sosl.ts @@ -0,0 +1,88 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { searchRecordsSoslOutputSchema } from '../../output-schemas'; + +export const searchRecordsSosl = createAction({ + auth: salesforceAuth, + name: 'search_records_sosl', + classification: 'SEARCH', + displayName: 'Search Records (SOSL)', + description: 'Full-text search for a term across objects.', + audience: 'ai', + aiMetadata: { + description: + 'Full-text searches a word or phrase across all searchable fields of one or more objects (e.g. find "Acme" in Accounts, Contacts and Leads) and returns matches tagged with their object type. Use it when you only know a name, email or phrase and not which object or field holds it; for exact field filters use Run SOQL Query. The term needs at least 2 characters and results are capped by Limit (default 50, max 200). Read-only; safe to retry.', + idempotent: true, + }, + outputSchema: searchRecordsSoslOutputSchema, + props: { + search_term: Property.ShortText({ + displayName: 'Search Term', + description: 'Word or phrase to search for, e.g. Acme or jane@example.com.', + required: true, + }), + objects: Property.Array({ + displayName: 'Objects', + description: 'Object API names to search, e.g. Account, Contact, Lead. Leave empty to search all searchable objects.', + required: false, + }), + fields: Property.Array({ + displayName: 'Fields', + description: 'Field API names to return for every object listed (Id is always returned), e.g. Name. Every field must exist on every listed object.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of records to return (default 50, max 200).', + required: false, + defaultValue: 50, + }), + }, + async run(context) { + const term = context.propsValue.search_term.trim(); + if (term.length < 2) { + throw new Error('Search Term must contain at least 2 characters.'); + } + const objects = salesforceUtils + .toStringArray(context.propsValue.objects) + .map((object) => salesforceUtils.assertApiName({ value: object, fieldName: 'Objects' })); + const fields = salesforceUtils + .toStringArray(context.propsValue.fields) + .map((field) => salesforceUtils.assertApiName({ value: field, fieldName: 'Fields' })) + .filter((field) => field.toLowerCase() !== 'id'); + const limit = Math.min(Math.max(Math.floor(context.propsValue.limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT); + const selected = ['Id', ...fields].join(', '); + const returning = objects.length > 0 ? ` RETURNING ${objects.map((object) => `${object}(${selected})`).join(', ')}` : ''; + const sosl = `FIND {${salesforceUtils.escapeSosl(term)}} IN ALL FIELDS${returning} LIMIT ${limit}`; + const response = await callSalesforceApi<{ searchRecords?: unknown[] }>( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/search?q=${encodeURIComponent(sosl)}`, + undefined + ); + const records = (response.body.searchRecords ?? []).map((record) => ({ + object_type: getObjectType(record), + ...toFlatRecord(record), + })); + return { records, count: records.length }; + }, +}); + +function getObjectType(record: unknown): string | null { + if (!salesforceUtils.isRecord(record) || !salesforceUtils.isRecord(record['attributes'])) { + return null; + } + const type = record['attributes']['type']; + return typeof type === 'string' ? type : null; +} + +function toFlatRecord(record: unknown): Record { + const cleaned = salesforceUtils.cleanRecord(record); + return salesforceUtils.isRecord(cleaned) ? cleaned : {}; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/send-email-message.ts b/packages/pieces/community/salesforce/src/lib/action/ai/send-email-message.ts new file mode 100644 index 000000000000..8379ee4a3c93 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/send-email-message.ts @@ -0,0 +1,120 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { sendEmailMessageOutputSchema } from '../../output-schemas'; + +export const sendEmailMessage = createAction({ + auth: salesforceAuth, + name: 'send_email_message', + classification: 'WRITE', + displayName: 'Send Email Message', + description: 'Send a real email through Salesforce.', + audience: 'ai', + aiMetadata: { + description: + 'Actually delivers an email from Salesforce (the standard Send Email action) to email addresses and/or a Contact, Lead or User id, optionally logged against a related record. Pick it when the email must reach the inbox; to only record an email that was sent elsewhere use Log Email Activity. Counts against the org daily external email limit, which is very small on Developer Edition orgs. Not idempotent: every call sends another email.', + idempotent: false, + }, + outputSchema: sendEmailMessageOutputSchema, + props: { + email_addresses: Property.Array({ + displayName: 'Email Addresses', + description: 'Recipient email addresses. Required unless Recipient ID is set.', + required: false, + }), + recipient_id: Property.ShortText({ + displayName: 'Recipient ID', + description: 'Id of a Contact, Lead or User to send to.', + required: false, + }), + subject: Property.ShortText({ + displayName: 'Subject', + required: true, + }), + body: Property.LongText({ + displayName: 'Body', + description: 'Plain-text email body.', + required: true, + }), + sender_type: Property.StaticDropdown({ + displayName: 'Sender Type', + description: 'Who the email is sent from. Defaults to the current user.', + required: false, + options: { + options: [ + { label: 'Current User', value: 'CurrentUser' }, + { label: 'Default Workflow User', value: 'DefaultWorkflowUser' }, + { label: 'Org-Wide Email Address', value: 'OrgWideEmailAddress' }, + ], + }, + }), + sender_address: Property.ShortText({ + displayName: 'Sender Address', + description: 'Verified org-wide email address. Required when Sender Type is Org-Wide Email Address.', + required: false, + }), + related_record_id: Property.ShortText({ + displayName: 'Related Record ID', + description: 'Id of a record (Account, Opportunity, Case…) to log the email against.', + required: false, + }), + use_line_breaks: Property.Checkbox({ + displayName: 'Use Line Breaks', + description: 'Keep the line breaks of the body in the sent email.', + required: false, + }), + }, + async run(context) { + const { subject, body, sender_type, sender_address, use_line_breaks } = context.propsValue; + const emailAddresses = salesforceUtils.toStringArray(context.propsValue.email_addresses); + const recipientId = context.propsValue.recipient_id + ? salesforceUtils.assertId({ value: context.propsValue.recipient_id, fieldName: 'Recipient ID' }) + : undefined; + const relatedRecordId = context.propsValue.related_record_id + ? salesforceUtils.assertId({ value: context.propsValue.related_record_id, fieldName: 'Related Record ID' }) + : undefined; + if (emailAddresses.length === 0 && !recipientId) { + throw new Error('Provide at least one Email Address or a Recipient ID.'); + } + if (sender_type === 'OrgWideEmailAddress' && !sender_address) { + throw new Error('Sender Address is required when Sender Type is Org-Wide Email Address.'); + } + const input = salesforceUtils.compact({ + emailAddressesArray: emailAddresses.length > 0 ? emailAddresses : undefined, + recipientId, + relatedRecordId, + emailSubject: subject, + emailBody: body, + senderType: sender_type, + senderAddress: sender_address, + useLineBreaks: use_line_breaks ? true : undefined, + }); + const response = await callSalesforceApi( + HttpMethod.POST, + context.auth, + '/services/data/v56.0/actions/standard/emailSimple', + { inputs: [input] }, + ); + const result = response.body[0]; + if (!result || !result.isSuccess) { + const messages = (result?.errors ?? []).map((error) => error.message).join('; '); + throw new Error(`Salesforce did not send the email: ${messages || 'unknown error'}`); + } + return { + success: true, + email_addresses: emailAddresses, + recipient_id: recipientId ?? null, + related_record_id: relatedRecordId ?? null, + subject, + }; + }, +}); + +type EmailSimpleResult = { + actionName: string; + errors: { statusCode?: string; message: string; fields?: string[] }[] | null; + isSuccess: boolean; + outputValues: Record | null; +}; diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-account.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-account.ts new file mode 100644 index 000000000000..6b7052c0e5d0 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-account.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateAccount = createAction({ + auth: salesforceAuth, + name: 'update_account', + classification: 'WRITE', + displayName: 'Update Account', + description: 'Update fields on an existing account.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Account by id; only the fields you supply are changed, everything else is left as is. Use Create Account for new accounts and Search Accounts to find the id. At least one field is required; custom fields go in Additional Fields (set a value to null there to clear it). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + account_id: Property.ShortText({ displayName: 'Account ID', required: true }), + Name: Property.ShortText({ displayName: 'Account Name', required: false }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + Website: Property.ShortText({ displayName: 'Website', required: false }), + Industry: Property.ShortText({ displayName: 'Industry', required: false }), + Type: Property.ShortText({ displayName: 'Type', required: false }), + BillingStreet: Property.ShortText({ displayName: 'Billing Street', required: false }), + BillingCity: Property.ShortText({ displayName: 'Billing City', required: false }), + BillingState: Property.ShortText({ displayName: 'Billing State', required: false }), + BillingPostalCode: Property.ShortText({ displayName: 'Billing Postal Code', required: false }), + BillingCountry: Property.ShortText({ displayName: 'Billing Country', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + ParentId: Property.ShortText({ displayName: 'Parent Account ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { account_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ auth: context.auth, object: 'Account', recordId: account_id, fields, additionalFields: additional_fields }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-campaign.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-campaign.ts new file mode 100644 index 000000000000..52107150b9cd --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-campaign.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateCampaign = createAction({ + auth: salesforceAuth, + name: 'update_campaign', + classification: 'WRITE', + displayName: 'Update Campaign', + description: 'Update fields on an existing campaign.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Campaign by id, e.g. its status, dates (YYYY-MM-DD), active flag or budget; only the fields you supply change. Use Create Campaign for new campaigns. At least one field is required; custom fields go in Additional Fields (null clears a value). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + campaign_id: Property.ShortText({ displayName: 'Campaign ID', required: true }), + Name: Property.ShortText({ displayName: 'Name', required: false }), + Type: Property.ShortText({ displayName: 'Type', required: false }), + Status: Property.ShortText({ displayName: 'Status', required: false }), + StartDate: Property.ShortText({ displayName: 'Start Date', description: 'YYYY-MM-DD.', required: false }), + EndDate: Property.ShortText({ displayName: 'End Date', description: 'YYYY-MM-DD.', required: false }), + IsActive: crmUtils.booleanFilterProp({ displayName: 'Active', description: 'Leave empty to keep the current value.' }), + BudgetedCost: Property.Number({ displayName: 'Budgeted Cost', required: false }), + ExpectedRevenue: Property.Number({ displayName: 'Expected Revenue', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + ParentId: Property.ShortText({ displayName: 'Parent Campaign ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { campaign_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ + auth: context.auth, + object: 'Campaign', + recordId: campaign_id, + fields: { + ...fields, + StartDate: crmUtils.assertDate({ value: fields.StartDate, fieldName: 'Start Date' }), + EndDate: crmUtils.assertDate({ value: fields.EndDate, fieldName: 'End Date' }), + }, + additionalFields: additional_fields, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-opportunity.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-opportunity.ts new file mode 100644 index 000000000000..985574024cc2 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-opportunity.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateOpportunity = createAction({ + auth: salesforceAuth, + name: 'update_opportunity', + classification: 'WRITE', + displayName: 'Update Opportunity', + description: 'Update fields on an existing opportunity.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Opportunity by id, e.g. to move its stage, change the amount or push the close date (YYYY-MM-DD); only the fields you supply change. Use Create Opportunity for new deals and Search Opportunities to find the id. At least one field is required; custom fields go in Additional Fields (null clears a value). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + opportunity_id: Property.ShortText({ displayName: 'Opportunity ID', required: true }), + Name: Property.ShortText({ displayName: 'Name', required: false }), + StageName: Property.ShortText({ displayName: 'Stage', required: false }), + CloseDate: Property.ShortText({ displayName: 'Close Date', description: 'YYYY-MM-DD.', required: false }), + AccountId: Property.ShortText({ displayName: 'Account ID', required: false }), + Amount: Property.Number({ displayName: 'Amount', required: false }), + Probability: Property.Number({ displayName: 'Probability (%)', required: false }), + Type: Property.ShortText({ displayName: 'Type', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', required: false }), + NextStep: Property.ShortText({ displayName: 'Next Step', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + Pricebook2Id: Property.ShortText({ displayName: 'Price Book ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { opportunity_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ + auth: context.auth, + object: 'Opportunity', + recordId: opportunity_id, + fields: { ...fields, CloseDate: crmUtils.assertDate({ value: fields.CloseDate, fieldName: 'Close Date' }) }, + additionalFields: additional_fields, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-contact.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-contact.ts new file mode 100644 index 000000000000..0c0adb8e9f58 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-contact.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateSfContact = createAction({ + auth: salesforceAuth, + name: 'update_sf_contact', + classification: 'WRITE', + displayName: 'Update Contact', + description: 'Update fields on an existing contact.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Contact by id; only the fields you supply change, the rest stay as they are. Set Account ID to move the contact to another account. Use Create Contact for new people and Search Contacts to find the id. At least one field is required; custom fields go in Additional Fields (null clears a value). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + contact_id: Property.ShortText({ displayName: 'Contact ID', required: true }), + LastName: Property.ShortText({ displayName: 'Last Name', required: false }), + FirstName: Property.ShortText({ displayName: 'First Name', required: false }), + Email: Property.ShortText({ displayName: 'Email', required: false }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + MobilePhone: Property.ShortText({ displayName: 'Mobile Phone', required: false }), + Title: Property.ShortText({ displayName: 'Title', required: false }), + Department: Property.ShortText({ displayName: 'Department', required: false }), + AccountId: Property.ShortText({ displayName: 'Account ID', required: false }), + MailingCity: Property.ShortText({ displayName: 'Mailing City', required: false }), + MailingCountry: Property.ShortText({ displayName: 'Mailing Country', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { contact_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ auth: context.auth, object: 'Contact', recordId: contact_id, fields, additionalFields: additional_fields }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-lead.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-lead.ts new file mode 100644 index 000000000000..73724ce1cf0b --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-sf-lead.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateSfLead = createAction({ + auth: salesforceAuth, + name: 'update_sf_lead', + classification: 'WRITE', + displayName: 'Update Lead', + description: 'Update fields on an existing lead.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Lead by id; only the fields you supply change, the rest stay as they are. It does not convert the lead. Use Create Lead for new leads and Search Leads to find the id. At least one field is required; custom fields go in Additional Fields (null clears a value). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + lead_id: Property.ShortText({ displayName: 'Lead ID', required: true }), + LastName: Property.ShortText({ displayName: 'Last Name', required: false }), + Company: Property.ShortText({ displayName: 'Company', required: false }), + FirstName: Property.ShortText({ displayName: 'First Name', required: false }), + Email: Property.ShortText({ displayName: 'Email', required: false }), + Phone: Property.ShortText({ displayName: 'Phone', required: false }), + Title: Property.ShortText({ displayName: 'Title', required: false }), + Status: Property.ShortText({ displayName: 'Status', required: false }), + LeadSource: Property.ShortText({ displayName: 'Lead Source', required: false }), + Industry: Property.ShortText({ displayName: 'Industry', required: false }), + Website: Property.ShortText({ displayName: 'Website', required: false }), + City: Property.ShortText({ displayName: 'City', required: false }), + Country: Property.ShortText({ displayName: 'Country', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + OwnerId: Property.ShortText({ displayName: 'Owner ID', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { lead_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ auth: context.auth, object: 'Lead', recordId: lead_id, fields, additionalFields: additional_fields }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-sobject-record.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-sobject-record.ts new file mode 100644 index 000000000000..6677cbfc7ea5 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-sobject-record.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateSobjectRecord = createAction({ + auth: salesforceAuth, + name: 'update_sobject_record', + classification: 'WRITE', + displayName: 'Update Record', + description: 'Update fields on one record of any object.', + audience: 'ai', + aiMetadata: { + description: + 'Updates a single record of any Salesforce object by id; only the field API names you send change, every other field stays untouched (send null to clear a field). Use it when you have the record id; to match on an external id instead use Upsert Record by External ID, and for many records use Upsert Records Batch. Idempotent: resending the same values leaves the record in the same state.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account, Contact or My_Object__c.', + required: true, + }), + record_id: Property.ShortText({ + displayName: 'Record ID', + required: true, + }), + fields: Property.Json({ + displayName: 'Fields', + description: 'JSON object of field API names to new values. Only these fields change; use null to clear a field.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const recordId = salesforceUtils.assertId({ value: context.propsValue.record_id, fieldName: 'Record ID' }); + const fields = salesforceUtils.parseJsonObject({ value: context.propsValue.fields, fieldName: 'Fields' }) ?? {}; + const fieldNames = Object.keys(fields); + if (fieldNames.length === 0) { + throw new Error('Fields must contain at least one field to update.'); + } + await callSalesforceApi( + HttpMethod.PATCH, + context.auth, + `/services/data/v56.0/sobjects/${object}/${recordId}`, + fields + ); + return { id: recordId, success: true, updated_fields: fieldNames }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/update-task.ts b/packages/pieces/community/salesforce/src/lib/action/ai/update-task.ts new file mode 100644 index 000000000000..ffa0e32b3026 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/update-task.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { salesforceAuth } from '../../..'; +import { crmUtils } from '../../common/crm'; +import { updateRecordOutputSchema } from '../../output-schemas'; + +export const updateTask = createAction({ + auth: salesforceAuth, + name: 'update_task', + classification: 'WRITE', + displayName: 'Update Task', + description: 'Update fields on an existing task.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Salesforce Task by id, e.g. its subject, due date (YYYY-MM-DD), status, priority, assignee or linked records; only the fields you supply change. To simply mark it done use Complete Task. At least one field is required; custom fields go in Additional Fields (null clears a value). Safe to retry with the same values.', + idempotent: true, + }, + outputSchema: updateRecordOutputSchema, + props: { + task_id: Property.ShortText({ displayName: 'Task ID', required: true }), + Subject: Property.ShortText({ displayName: 'Subject', required: false }), + WhoId: Property.ShortText({ displayName: 'Who ID', description: 'Contact or Lead id.', required: false }), + WhatId: Property.ShortText({ displayName: 'What ID', description: 'Account, Opportunity, Case or other record id.', required: false }), + OwnerId: Property.ShortText({ displayName: 'Assigned To (User ID)', required: false }), + ActivityDate: Property.ShortText({ displayName: 'Due Date', description: 'YYYY-MM-DD.', required: false }), + Status: Property.ShortText({ displayName: 'Status', required: false }), + Priority: Property.ShortText({ displayName: 'Priority', required: false }), + Description: Property.LongText({ displayName: 'Description', required: false }), + additional_fields: crmUtils.additionalFieldsProp, + }, + async run(context) { + const { task_id, additional_fields, ...fields } = context.propsValue; + return crmUtils.updateRecord({ + auth: context.auth, + object: 'Task', + recordId: task_id, + fields: { ...fields, ActivityDate: crmUtils.assertDate({ value: fields.ActivityDate, fieldName: 'Due Date' }) }, + additionalFields: additional_fields, + }); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/upload-file.ts b/packages/pieces/community/salesforce/src/lib/action/ai/upload-file.ts new file mode 100644 index 000000000000..51deaf584c00 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/upload-file.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi, querySalesforceApi } from '../../common'; +import { QueryResult, salesforceUtils } from '../../common/utils'; +import { uploadFileOutputSchema } from '../../output-schemas'; + +export const uploadFile = createAction({ + auth: salesforceAuth, + name: 'upload_file', + classification: 'WRITE', + displayName: 'Upload File', + description: 'Upload a file to Salesforce Files, optionally linked to a record.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a file to Salesforce Files (creates a ContentVersion) and, when a record id is given, shares it on that record. Returns the content document id (starts with 069), which is what Get File Info, Download File and Delete File take. Not idempotent: every call uploads another file.', + idempotent: false, + }, + outputSchema: uploadFileOutputSchema, + props: { + file: Property.File({ + displayName: 'File', + required: true, + }), + file_name: Property.ShortText({ + displayName: 'File Name', + description: 'Name with extension, e.g. report.pdf. Defaults to the uploaded file name.', + required: false, + }), + record_id: Property.ShortText({ + displayName: 'Record ID', + description: 'Id of the record to attach the file to.', + required: false, + }), + }, + async run(context) { + const { file } = context.propsValue; + const fileName = context.propsValue.file_name?.trim() || file.filename; + const recordId = context.propsValue.record_id + ? salesforceUtils.assertId({ value: context.propsValue.record_id, fieldName: 'Record ID' }) + : undefined; + const created = await callSalesforceApi<{ id: string; success: boolean }>( + HttpMethod.POST, + context.auth, + '/services/data/v56.0/sobjects/ContentVersion', + salesforceUtils.compact({ + Title: fileName, + PathOnClient: fileName, + VersionData: file.base64, + FirstPublishLocationId: recordId, + }), + ); + const versionId = salesforceUtils.assertId({ value: created.body.id, fieldName: 'ContentVersion id' }); + const version = await querySalesforceApi>( + HttpMethod.GET, + context.auth, + `SELECT ContentDocumentId FROM ContentVersion WHERE Id = '${versionId}'`, + ); + return { + content_version_id: versionId, + content_document_id: version.body.records[0]?.ContentDocumentId ?? null, + title: fileName, + record_id: recordId ?? null, + }; + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/upsert-record-by-external-id.ts b/packages/pieces/community/salesforce/src/lib/action/ai/upsert-record-by-external-id.ts new file mode 100644 index 000000000000..73c8a9bf6b8e --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/upsert-record-by-external-id.ts @@ -0,0 +1,90 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { upsertRecordByExternalIdOutputSchema } from '../../output-schemas'; + +export const upsertRecordByExternalId = createAction({ + auth: salesforceAuth, + name: 'upsert_record_by_external_id', + classification: 'WRITE', + displayName: 'Upsert Record by External ID', + description: 'Create or update one record matched on an external id field.', + audience: 'ai', + aiMetadata: { + description: + 'Creates or updates a single Salesforce record matched on an external id field: updates the match if one exists, otherwise creates it, and reports which happened. Use it to sync from another system without duplicates; for up to 200 records use Upsert Records Batch, and to update by Salesforce id use Update Record. Do not include the external id field inside Fields. Idempotent on the external id: rerunning updates rather than duplicates.', + idempotent: true, + }, + outputSchema: upsertRecordByExternalIdOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or My_Object__c.', + required: true, + }), + external_id_field: Property.ShortText({ + displayName: 'External ID Field', + description: 'API name of the external id field, e.g. My_Ext_Id__c.', + required: true, + }), + external_id_value: Property.ShortText({ + displayName: 'External ID Value', + required: true, + }), + fields: Property.Json({ + displayName: 'Fields', + description: 'JSON object of field API names to values to set on the record.', + required: true, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const externalField = salesforceUtils.assertApiName({ + value: context.propsValue.external_id_field, + fieldName: 'External ID Field', + }); + const value = context.propsValue.external_id_value.trim(); + if (value.length === 0) { + throw new Error('External ID Value is required.'); + } + const fields = salesforceUtils.parseJsonObject({ value: context.propsValue.fields, fieldName: 'Fields' }) ?? {}; + const fieldNames = Object.keys(fields); + if (fieldNames.length === 0) { + throw new Error('Fields must contain at least one field.'); + } + const response = await callSalesforceApi<{ id?: string; success?: boolean; created?: boolean } | string>( + HttpMethod.PATCH, + context.auth, + `/services/data/v56.0/sobjects/${object}/${externalField}/${encodeURIComponent(value)}`, + fields + ); + const body = salesforceUtils.isRecord(response.body) ? response.body : {}; + const id = + typeof body['id'] === 'string' + ? body['id'] + : await resolveRecordId({ auth: context.auth, object, externalField, value }); + return { + id, + success: true, + created: response.status === 201, + external_id_field: externalField, + external_id_value: value, + updated_fields: fieldNames, + }; + }, +}); + +async function resolveRecordId({ auth, object, externalField, value }: { auth: OAuth2PropertyValue; object: string; externalField: string; value: string }): Promise { + if (externalField === 'Id') { + return value; + } + const response = await callSalesforceApi<{ Id?: string }>( + HttpMethod.GET, + auth, + `/services/data/v56.0/sobjects/${object}/${externalField}/${encodeURIComponent(value)}?fields=Id`, + undefined + ); + return response.body.Id ?? null; +} diff --git a/packages/pieces/community/salesforce/src/lib/action/ai/upsert-records-batch.ts b/packages/pieces/community/salesforce/src/lib/action/ai/upsert-records-batch.ts new file mode 100644 index 000000000000..2643eaf680b2 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/ai/upsert-records-batch.ts @@ -0,0 +1,71 @@ +import { createAction, isNil, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { salesforceAuth } from '../../..'; +import { callSalesforceApi } from '../../common'; +import { salesforceUtils } from '../../common/utils'; +import { CompositeResult, recordsUtils } from '../../common/records'; +import { upsertRecordsBatchOutputSchema } from '../../output-schemas'; + +export const upsertRecordsBatch = createAction({ + auth: salesforceAuth, + name: 'upsert_records_batch', + classification: 'WRITE', + displayName: 'Upsert Records Batch', + description: 'Create or update up to 200 records matched on an external id field.', + audience: 'ai', + aiMetadata: { + description: + 'Creates or updates up to 200 records of one Salesforce object in a single call, matching each on an external id field, and returns a per-record result with a created flag in input order. Use it to sync many records from another system; for one record use Upsert Record by External ID. Every record must include the external id field. Idempotent on the external id: rerunning updates rather than duplicates.', + idempotent: true, + }, + outputSchema: upsertRecordsBatchOutputSchema, + props: { + object: Property.ShortText({ + displayName: 'Object', + description: 'Object API name, e.g. Account or My_Object__c.', + required: true, + }), + external_id_field: Property.ShortText({ + displayName: 'External ID Field', + description: 'API name of the external id field used to match, e.g. My_Ext_Id__c.', + required: true, + }), + records: Property.Json({ + displayName: 'Records', + description: 'JSON array of up to 200 objects; each must include the external id field, e.g. [{"My_Ext_Id__c": "A-1", "Name": "Acme"}].', + required: true, + }), + all_or_none: Property.Checkbox({ + displayName: 'All or None', + description: 'Roll back every record if any record fails.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const object = salesforceUtils.assertApiName({ value: context.propsValue.object, fieldName: 'Object' }); + const externalField = salesforceUtils.assertApiName({ + value: context.propsValue.external_id_field, + fieldName: 'External ID Field', + }); + const records = recordsUtils.parseRecordList({ + value: context.propsValue.records, + fieldName: 'Records', + max: recordsUtils.MAX_COMPOSITE_RECORDS, + }); + const missing = records.findIndex((record) => isNil(record[externalField]) || record[externalField] === ''); + if (missing !== -1) { + throw new Error(`Records[${missing}] is missing the external id field ${externalField}.`); + } + const response = await callSalesforceApi( + HttpMethod.PATCH, + context.auth, + `/services/data/v56.0/composite/sobjects/${object}/${externalField}`, + { + allOrNone: context.propsValue.all_or_none ?? false, + records: records.map((record) => ({ ...record, attributes: { type: object } })), + } + ); + return recordsUtils.formatCompositeResults(response.body); + }, +}); diff --git a/packages/pieces/community/salesforce/src/lib/action/create-attachment.ts b/packages/pieces/community/salesforce/src/lib/action/create-attachment.ts index 5798bade57ae..7d18faac97fd 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-attachment.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-attachment.ts @@ -2,6 +2,7 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createAttachment = createAction({ auth: salesforceAuth, @@ -10,6 +11,7 @@ export const createAttachment = createAction({ description: 'Creates a legacy Attachment record. Salesforce recommends using "Add File to Record" for modern apps.', audience: 'both', aiMetadata: { description: 'Attach a file to a record using the legacy Attachment object (single parent, no sharing). Prefer modern Files/ContentDocument when available; this is for older orgs or when a classic Attachment is specifically required. Not idempotent — repeated calls create duplicate attachments.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { object: salesforcesCommon.object, parent_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/create-case.ts b/packages/pieces/community/salesforce/src/lib/action/create-case.ts index 744dd5fad9e6..d704c5fc054a 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-case.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-case.ts @@ -2,6 +2,7 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createCase = createAction({ auth: salesforceAuth, @@ -10,6 +11,7 @@ export const createCase = createAction({ description: 'Creates a Case, which represents a customer issue or problem.', audience: 'both', aiMetadata: { description: 'Create a new support Case (customer issue/problem) with a required Subject and optional status, priority, origin, and links to an Account or Contact. Not idempotent — each call opens a separate Case, so guard against duplicate submissions.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { Subject: Property.ShortText({ displayName: 'Subject', diff --git a/packages/pieces/community/salesforce/src/lib/action/create-contact.ts b/packages/pieces/community/salesforce/src/lib/action/create-contact.ts index 7338f02131a6..d422315e048c 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-contact.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-contact.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createContact = createAction({ auth: salesforceAuth, name: 'create_contact', displayName: 'Create Contact', description: 'Creates a new contact record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new Contact in Salesforce; Last Name is required and the contact can be linked to an existing Account. Not idempotent — calling it twice creates duplicate contacts, so check for an existing match (e.g. via Find Record) first if dedup matters.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { LastName: Property.ShortText({ displayName: 'Last Name', diff --git a/packages/pieces/community/salesforce/src/lib/action/create-lead.ts b/packages/pieces/community/salesforce/src/lib/action/create-lead.ts index ba4894e164cb..467fe0287526 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-lead.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-lead.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createLead = createAction({ auth: salesforceAuth, name: 'create_lead', displayName: 'Create Lead', description: 'Creates a new lead.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new Lead; Last Name and Company are required. Not idempotent — each call adds another Lead, so dedup beforehand if needed. Use Update Lead to modify an existing one.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { LastName: Property.ShortText({ displayName: 'Last Name', diff --git a/packages/pieces/community/salesforce/src/lib/action/create-note.ts b/packages/pieces/community/salesforce/src/lib/action/create-note.ts index 573e908bb110..3f113865aa12 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-note.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-note.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createNote = createAction({ auth: salesforceAuth, name: 'create_note', displayName: 'Create Note', description: 'Creates a note and attaches it to a record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a classic Salesforce Note (title and body) and attach it to a parent record via its record Id. Use to add a free-text note to an existing record. Not idempotent: each call creates a new Note, so re-running adds a duplicate.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { object: salesforcesCommon.object, parent_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/create-opportunity.ts b/packages/pieces/community/salesforce/src/lib/action/create-opportunity.ts index 90e0bd9482ba..f475545a78f5 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-opportunity.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-opportunity.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createOpportunity = createAction({ auth: salesforceAuth, name: 'create_opportunity', displayName: 'Create Opportunity', description: 'Creates a new opportunity.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new sales Opportunity; Name, Close Date (YYYY-MM-DD), and Stage are required, with an optional Account link and Amount. Not idempotent — each call creates another Opportunity.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { Name: Property.ShortText({ displayName: 'Name', diff --git a/packages/pieces/community/salesforce/src/lib/action/create-record.ts b/packages/pieces/community/salesforce/src/lib/action/create-record.ts index 682ede245770..190a1bb511ed 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-record.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-record.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createRecord = createAction({ auth: salesforceAuth, name: 'create_record', displayName: 'Create Record', description: 'Create a record of a given object.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new Salesforce record of any standard or custom object (Account, Contact, Lead, etc.) by supplying the object API name plus a JSON map of field values. Pick this for the general "insert one record" case; use Create Object (Advanced) only when you need the raw API response envelope rather than the record body. Not idempotent: each call inserts a new record with a new Id, so re-running creates duplicates.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { object: salesforcesCommon.object, data: Property.Json({ diff --git a/packages/pieces/community/salesforce/src/lib/action/create-task.ts b/packages/pieces/community/salesforce/src/lib/action/create-task.ts index 527d799501a5..5d82e425c19e 100644 --- a/packages/pieces/community/salesforce/src/lib/action/create-task.ts +++ b/packages/pieces/community/salesforce/src/lib/action/create-task.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const createTask = createAction({ auth: salesforceAuth, name: 'create_task', displayName: 'Create Task', description: 'Creates a new task.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Create a new activity Task with a required Subject and optional owner, status, priority, and links to a Contact/Lead (WhoId) or other object (WhatId). Not idempotent — each call creates a separate task.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { Subject: Property.ShortText({ displayName: 'Subject', diff --git a/packages/pieces/community/salesforce/src/lib/action/delete-opportunity.ts b/packages/pieces/community/salesforce/src/lib/action/delete-opportunity.ts index 0f280fc7a106..6e4aea971048 100644 --- a/packages/pieces/community/salesforce/src/lib/action/delete-opportunity.ts +++ b/packages/pieces/community/salesforce/src/lib/action/delete-opportunity.ts @@ -2,14 +2,16 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { successOutputSchema } from '../output-schemas'; export const deleteOpportunity = createAction({ auth: salesforceAuth, name: 'delete_opportunity', displayName: 'Delete Opportunity', description: 'Deletes an opportunity.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Delete a single Opportunity by its ID. Destructive and irreversible; not idempotent, since deleting an already-removed Opportunity errors. For other object types use the generic Delete Record.', idempotent: false }, + outputSchema: successOutputSchema, props: { opportunity_id: salesforcesCommon.opportunity, }, diff --git a/packages/pieces/community/salesforce/src/lib/action/delete-record.ts b/packages/pieces/community/salesforce/src/lib/action/delete-record.ts index 807156482e7b..16b99a3d8c37 100644 --- a/packages/pieces/community/salesforce/src/lib/action/delete-record.ts +++ b/packages/pieces/community/salesforce/src/lib/action/delete-record.ts @@ -2,14 +2,16 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { successOutputSchema } from '../output-schemas'; export const deleteRecord = createAction({ auth: salesforceAuth, name: 'delete_record', displayName: 'Delete Record', description: 'Deletes an existing record in an object.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Delete a single record of any object by its ID. Destructive and irreversible from here; not idempotent, since a repeat call on an already-deleted ID errors. Use this generic delete for objects without a dedicated one (Delete Opportunity exists separately).', idempotent: false }, + outputSchema: successOutputSchema, props: { object: salesforcesCommon.object, record_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/export-report.ts b/packages/pieces/community/salesforce/src/lib/action/export-report.ts index 6e6d09a5f3d3..2081d1e0de5c 100644 --- a/packages/pieces/community/salesforce/src/lib/action/export-report.ts +++ b/packages/pieces/community/salesforce/src/lib/action/export-report.ts @@ -6,6 +6,7 @@ import { } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { exportReportOutputSchema } from '../output-schemas'; export const exportReport = createAction({ auth: salesforceAuth, @@ -14,6 +15,7 @@ export const exportReport = createAction({ description: 'Export a Salesforce report as an Excel file.', audience: 'both', aiMetadata: { description: 'Run an existing Salesforce report by its ID and return its results as a downloadable Excel (.xlsx) file. Read-only — it does not change the report or any records, and the report definition must already exist.', idempotent: true }, + outputSchema: exportReportOutputSchema, props: { report_id: salesforcesCommon.report, }, diff --git a/packages/pieces/community/salesforce/src/lib/action/find-child-records.ts b/packages/pieces/community/salesforce/src/lib/action/find-child-records.ts index 9208bcfe30b9..76786469a131 100644 --- a/packages/pieces/community/salesforce/src/lib/action/find-child-records.ts +++ b/packages/pieces/community/salesforce/src/lib/action/find-child-records.ts @@ -2,14 +2,16 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { findChildRecordsOutputSchema } from '../output-schemas'; export const findChildRecords = createAction({ auth: salesforceAuth, name: 'find_child_records', displayName: 'Find Child Records', description: 'Finds child records related to a parent record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'List the child records on one side of a parent record relationship (e.g. the Contacts under an Account) by parent ID and child-relationship name (read-only). Use when you already have a parent record and want its related records via a defined relationship; for arbitrary filtered lookups use Run Query instead.', idempotent: true }, + outputSchema: findChildRecordsOutputSchema, props: { parent_object: salesforcesCommon.object, parent_id: salesforcesCommon.parentRecord, diff --git a/packages/pieces/community/salesforce/src/lib/action/find-record.ts b/packages/pieces/community/salesforce/src/lib/action/find-record.ts index 9c8594de33ce..b79492688df8 100644 --- a/packages/pieces/community/salesforce/src/lib/action/find-record.ts +++ b/packages/pieces/community/salesforce/src/lib/action/find-record.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { querySalesforceApi, salesforcesCommon } from '../common'; +import { rawQueryOutputSchema } from '../output-schemas'; export const findRecord = createAction({ auth: salesforceAuth, name: 'find_record', displayName: 'Find Record', description: 'Finds a record by a field value.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Look up records of a chosen Salesforce object where a single field equals an exact value (read-only, returns up to 200 matches). Pick this for simple equality lookups by a known field such as Email or Name; for multi-condition filters, joins, or sorting use Run Query (SOQL) instead.', idempotent: true }, + outputSchema: rawQueryOutputSchema, props: { object: salesforcesCommon.object, field: salesforcesCommon.field, diff --git a/packages/pieces/community/salesforce/src/lib/action/find-records-by-query.ts b/packages/pieces/community/salesforce/src/lib/action/find-records-by-query.ts index 5305a8b68ca9..37e047cb21c0 100644 --- a/packages/pieces/community/salesforce/src/lib/action/find-records-by-query.ts +++ b/packages/pieces/community/salesforce/src/lib/action/find-records-by-query.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { querySalesforceApi, salesforcesCommon } from '../common'; +import { rawQueryOutputSchema } from '../output-schemas'; export const findRecordsByQuery = createAction({ auth: salesforceAuth, name: 'find_records_by_query', displayName: 'Find Records by Query (Advanced)', description: 'Finds records in an object using a SOQL WHERE clause.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Search records of one Salesforce object by providing the object API name and a SOQL WHERE clause (no "WHERE" keyword); returns up to 200 matching records with all fields. Use this to look up or filter existing records by field conditions. Read-only and idempotent. Constraint: the WHERE clause is interpolated directly into the query, so it must be valid SOQL and properly escaped.', idempotent: true }, + outputSchema: rawQueryOutputSchema, props: { object: salesforcesCommon.object, where_clause: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/action/get-record-attachments.ts b/packages/pieces/community/salesforce/src/lib/action/get-record-attachments.ts index 06757d3fbc0a..c3c88c8a5911 100644 --- a/packages/pieces/community/salesforce/src/lib/action/get-record-attachments.ts +++ b/packages/pieces/community/salesforce/src/lib/action/get-record-attachments.ts @@ -2,6 +2,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { querySalesforceApi, salesforcesCommon } from '../common'; +import { getRecordAttachmentsOutputSchema } from '../output-schemas'; interface QueryResult { @@ -15,6 +16,7 @@ export const getRecordAttachments = createAction({ description: 'Get all attachments (both classic and modern Files) for a record.', audience: 'both', aiMetadata: { description: 'List the files attached to a record, covering both legacy Attachment objects and modern Files (ContentDocument) in one read-only call. Use to discover what is attached to a record by its ID; it returns metadata only, not the file contents.', idempotent: true }, + outputSchema: getRecordAttachmentsOutputSchema, props: { object: salesforcesCommon.object, record_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/run-report.ts b/packages/pieces/community/salesforce/src/lib/action/run-report.ts index 6c4070abbb90..3ddc799826f4 100644 --- a/packages/pieces/community/salesforce/src/lib/action/run-report.ts +++ b/packages/pieces/community/salesforce/src/lib/action/run-report.ts @@ -2,6 +2,8 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { reportUtils, SalesforceReportResponse } from '../common/report'; +import { runReportSyncOutputSchema } from '../output-schemas'; export const runReport = createAction({ auth: salesforceAuth, @@ -9,8 +11,9 @@ export const runReport = createAction({ displayName: 'Run Report', description: 'Execute a Salesforce analytics report and return the results as easy-to-use rows.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Run an existing Salesforce analytics report by its report Id and get back flattened rows (detail and grouping columns) instead of raw factMap structures. Optionally pass dynamic filters to override the report\'s saved filters for this run. Use to read aggregated/reported data rather than to query raw records. Read-only and idempotent — it executes the report without changing it.', idempotent: true }, + outputSchema: runReportSyncOutputSchema, props: { report_id: salesforcesCommon.report, filters: Property.Json({ @@ -50,161 +53,6 @@ export const runReport = createAction({ const reportData = response.body; - return transformReportToRows(reportData); + return reportUtils.transformReportToRows(reportData); }, }); - -interface SalesforceReportResponse { - attributes: { - reportId: string; - reportName: string; - }; - reportMetadata: { - detailColumns: string[]; - name: string; - reportFormat: string; - aggregates: string[]; - groupingsDown: { - name: string; - sortOrder: string; - dateGranularity: string; - column: string; - }[]; - }; - reportExtendedMetadata: { - detailColumnInfo: Record; - groupingColumnInfo: Record; - aggregateColumnInfo: Record; - }; - factMap: Record< - string, - { - rows: { dataCells: { label: string; value: unknown }[] }[]; - aggregates: { label: string; value: unknown }[]; - } - >; - groupingsDown: { - groupings: { key: string; label: string; value: unknown }[]; - }; -} - -function transformReportToRows(report: SalesforceReportResponse): { - reportName: string; - reportId: string; - totalRows: number; - columns: string[]; - rows: Record[]; -} { - const detailColumns = report.reportMetadata?.detailColumns ?? []; - const detailColumnInfo = report.reportExtendedMetadata?.detailColumnInfo ?? {}; - const groupingsDown = report.reportMetadata?.groupingsDown ?? []; - const groupingColumnInfo = report.reportExtendedMetadata?.groupingColumnInfo ?? {}; - const factMap = report.factMap ?? {}; - - // Build ordered list of column labels for detail columns - const columnLabels = detailColumns.map((col) => detailColumnInfo[col]?.label ?? col); - - // Build grouping column labels - const groupingLabels = groupingsDown.map((g) => groupingColumnInfo[g.name]?.label ?? g.name); - - const allRows: Record[] = []; - - // Collect grouping labels from groupingsDown for grouped/summary reports - const groupingValues = extractGroupingValues(report.groupingsDown?.groupings ?? []); - - // Iterate over all factMap entries to collect rows - // Keys: "T!T" (tabular/grand total), "0!T", "1!T" (summary groups), "0!0", "1!0" (matrix), etc. - for (const [factMapKey, factMapEntry] of Object.entries(factMap)) { - if (!factMapEntry?.rows) continue; - - // Determine grouping context from the factMap key - const groupContext = resolveGroupingContext(factMapKey, groupingValues, groupingLabels); - - for (const row of factMapEntry.rows) { - const rowObj: Record = {}; - - // Add grouping columns if present - for (const [key, value] of Object.entries(groupContext)) { - rowObj[key] = value; - } - - // Map each data cell to its column label - if (row.dataCells) { - for (let i = 0; i < row.dataCells.length; i++) { - const label = columnLabels[i] ?? `Column_${i}`; - const cell = row.dataCells[i]; - let value = cell.label ?? cell.value; - if (value === '-' || value === '--') { - value = ''; - } - rowObj[label] = value; - } - } - - allRows.push(rowObj); - } - } - - return { - reportName: - report.attributes?.reportName ?? report.reportMetadata?.name ?? 'Unknown Report', - reportId: report.attributes.reportId ?? 'Unknown Report', - totalRows: allRows.length, - columns: [...groupingLabels, ...columnLabels], - rows: allRows, - }; -} - -function extractGroupingValues( - groupings: { - key: string; - label: string; - value: unknown; - groupings?: { key: string; label: string; value: unknown }[]; - }[], - depth = 0, - result: Record = {} -): Record { - for (const grouping of groupings) { - result[grouping.key] = { label: grouping.label, depth }; - if (grouping.groupings && grouping.groupings.length > 0) { - extractGroupingValues(grouping.groupings, depth + 1, result); - } - } - return result; -} - -function resolveGroupingContext( - factMapKey: string, - groupingValues: Record, - groupingLabels: string[] -): Record { - const context: Record = {}; - - // factMap keys are like "0!T", "0_1!T", "T!T", etc. - // The part before "!" represents row groupings, after "!" represents column groupings - const [rowPart] = factMapKey.split('!'); - - if (rowPart === 'T' || !rowPart) { - return context; // Grand total or no grouping - } - - // Row grouping keys can be like "0", "0_1" (nested groupings) - const rowKeys = rowPart.split('_'); - - let currentKey = ''; - - for (let depth = 0; depth < rowKeys.length; depth++) { - currentKey = depth === 0 ? rowKeys[depth] : `${currentKey}_${rowKeys[depth]}`; - - const groupInfo = groupingValues[currentKey]; - - if (!groupInfo) continue; - - const columnLabel = groupingLabels[depth] ?? `Group_${depth}`; - - context[columnLabel] = groupInfo.label; - } - - return context; -} diff --git a/packages/pieces/community/salesforce/src/lib/action/run-sf-query.ts b/packages/pieces/community/salesforce/src/lib/action/run-sf-query.ts index 504b3447eeb7..f6e088fa2e39 100644 --- a/packages/pieces/community/salesforce/src/lib/action/run-sf-query.ts +++ b/packages/pieces/community/salesforce/src/lib/action/run-sf-query.ts @@ -3,14 +3,16 @@ import { salesforcesCommon } from '../common'; import { HttpMethod } from '@activepieces/pieces-common'; import { querySalesforceApi } from '../common'; import { salesforceAuth } from '../..'; +import { runQueryOutputSchema } from '../output-schemas'; export const runQuery = createAction({ auth: salesforceAuth, name: 'run_query', displayName: 'Run Query (Advanced)', description: 'Run a salesforce query', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Run an arbitrary SOQL query string and return the matching records (read-only). Use this for any read needing multiple conditions, field selection, relationships, ordering, or aggregation that the simpler Find Record lookup cannot express; the caller must supply valid SOQL.', idempotent: true }, + outputSchema: runQueryOutputSchema, props: { query: Property.ShortText({ displayName: 'Query', diff --git a/packages/pieces/community/salesforce/src/lib/action/send-email.ts b/packages/pieces/community/salesforce/src/lib/action/send-email.ts index 3c479aa542be..ca5c4e08cec0 100644 --- a/packages/pieces/community/salesforce/src/lib/action/send-email.ts +++ b/packages/pieces/community/salesforce/src/lib/action/send-email.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi ,salesforcesCommon } from '../common'; +import { sobjectCreateResponseOutputSchema } from '../output-schemas'; export const sendEmail = createAction({ auth: salesforceAuth, name: 'send_email', displayName: 'Send Email', description: 'Sends an email to a Contact or Lead by creating an EmailMessage record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Send an email to a Contact or Lead (body may be plain text or HTML) and log it as an EmailMessage, optionally related to another record. Not idempotent — each call sends and records another email.', idempotent: false }, + outputSchema: sobjectCreateResponseOutputSchema, props: { recipientId: salesforcesCommon.recipient, subject: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/action/update-contact.ts b/packages/pieces/community/salesforce/src/lib/action/update-contact.ts index b9813273264d..9183fd9a4701 100644 --- a/packages/pieces/community/salesforce/src/lib/action/update-contact.ts +++ b/packages/pieces/community/salesforce/src/lib/action/update-contact.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { successOutputSchema } from '../output-schemas'; export const updateContact = createAction({ auth: salesforceAuth, name: 'update_contact', displayName: 'Update Contact', description: 'Update an existing contact.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Update fields (name, email, phone, title, account, or arbitrary extra fields) on an existing Salesforce Contact identified by its record Id. Use when you already have the contact Id and want to modify it rather than create one. Idempotent: re-applying the same field values to the same Id yields the same result; omitted fields are left unchanged, and a call with no fields is a no-op.', idempotent: true }, + outputSchema: successOutputSchema, props: { contact_id: salesforcesCommon.contact, FirstName: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/action/update-lead.ts b/packages/pieces/community/salesforce/src/lib/action/update-lead.ts index 33c3cc16aa66..ef3aeee78ac4 100644 --- a/packages/pieces/community/salesforce/src/lib/action/update-lead.ts +++ b/packages/pieces/community/salesforce/src/lib/action/update-lead.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { successOutputSchema } from '../output-schemas'; export const updateLead = createAction({ auth: salesforceAuth, name: 'update_lead', displayName: 'Update Lead', description: 'Update an existing lead.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Update fields on an existing Lead selected by its ID; only the fields you provide are changed and the Lead must already exist. Use Create Lead to add a new one; this does not convert the Lead.', idempotent: false }, + outputSchema: successOutputSchema, props: { lead_id: salesforcesCommon.lead, FirstName: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/action/update-record.ts b/packages/pieces/community/salesforce/src/lib/action/update-record.ts index f2e0a7efb903..0b78c4f72d49 100644 --- a/packages/pieces/community/salesforce/src/lib/action/update-record.ts +++ b/packages/pieces/community/salesforce/src/lib/action/update-record.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; import { callSalesforceApi, salesforcesCommon } from '../common'; +import { successOutputSchema } from '../output-schemas'; export const updateRecord = createAction({ auth: salesforceAuth, name: 'update_record', displayName: 'Update Record', description: 'Updates an existing record.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Update an existing record of any object, selected by object type and record ID, with a JSON map of fields to change; only supplied fields are modified and the record must already exist. The standard update action — use a stable record ID, not an external ID (use Batch/Bulk Upsert for external-ID matching).', idempotent: false }, + outputSchema: successOutputSchema, props: { object: salesforcesCommon.object, record_id: salesforcesCommon.record, diff --git a/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id-bulk.ts b/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id-bulk.ts index cd9197496c24..c7c4749729d2 100644 --- a/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id-bulk.ts +++ b/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id-bulk.ts @@ -9,6 +9,7 @@ import { import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; +import { upsertByExternalIdBulkOutputSchema } from '../output-schemas'; export const upsertByExternalIdBulk = createAction({ auth: salesforceAuth, @@ -17,6 +18,7 @@ export const upsertByExternalIdBulk = createAction({ description: 'Bulk upsert a record by external id', audience: 'both', aiMetadata: { description: 'Insert-or-update many records at once via the Bulk API, matching on a stable external-ID field and accepting CSV input. Idempotent on the external ID — rerunning with the same data does not create duplicates. Pick this for large batches; use Batch Upsert for small JSON payloads.', idempotent: true }, + outputSchema: upsertByExternalIdBulkOutputSchema, props: { object: salesforcesCommon.object, external_field: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id.ts b/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id.ts index ebb94de57228..0184049993a5 100644 --- a/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id.ts +++ b/packages/pieces/community/salesforce/src/lib/action/upsert-by-external-id.ts @@ -2,14 +2,16 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { callSalesforceApi, salesforcesCommon } from '../common'; import { HttpMethod } from '@activepieces/pieces-common'; import { salesforceAuth } from '../..'; +import { upsertByExternalIdOutputSchema } from '../output-schemas'; export const upsertByExternalId = createAction({ auth: salesforceAuth, name: 'upsert_by_external_id', displayName: 'Batch Upsert (Advanced)', description: 'Batch upsert a record by external id', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Insert-or-update a small batch of records via the composite API, matching on a stable external-ID field and accepting JSON input. Idempotent on the external ID — rerunning with the same data updates rather than duplicates. Use Bulk Upsert for large CSV loads.', idempotent: true }, + outputSchema: upsertByExternalIdOutputSchema, props: { object: salesforcesCommon.object, external_field: Property.ShortText({ diff --git a/packages/pieces/community/salesforce/src/lib/common/bulk.ts b/packages/pieces/community/salesforce/src/lib/common/bulk.ts new file mode 100644 index 000000000000..7ca09bb93c19 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/common/bulk.ts @@ -0,0 +1,85 @@ +import { salesforceUtils } from './utils'; + +function parseCsv(text: string): Record[] { + const rows = tokenizeCsv(text); + if (rows.length === 0) { + return []; + } + const [header, ...data] = rows; + return data + .filter((row) => !(row.length === 1 && row[0] === '')) + .map((row) => Object.fromEntries(header.map((column, index) => [column, row[index] ?? '']))); +} + +function formatJob(body: unknown): Record { + if (!salesforceUtils.isRecord(body)) { + return {}; + } + return Object.fromEntries(JOB_FIELDS.map((field) => [field, body[field] ?? null])); +} + +export const bulkUtils = { + parseCsv, + formatJob, + BULK_API_PATH: '/services/data/v58.0/jobs', +}; + +function tokenizeCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + let touched = false; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (inQuotes) { + if (char === '"' && text[i + 1] === '"') { + field += '"'; + i++; + } else if (char === '"') { + inQuotes = false; + } else { + field += char; + } + continue; + } + if (char === '"') { + inQuotes = true; + touched = true; + } else if (char === ',') { + row.push(field); + field = ''; + touched = true; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && text[i + 1] === '\n') { + i++; + } + row.push(field); + rows.push(row); + row = []; + field = ''; + touched = false; + } else { + field += char; + touched = true; + } + } + if (touched || field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows; +} + +const JOB_FIELDS = [ + 'id', + 'state', + 'object', + 'operation', + 'numberRecordsProcessed', + 'numberRecordsFailed', + 'errorMessage', + 'createdDate', + 'systemModstamp', + 'totalProcessingTime', +]; diff --git a/packages/pieces/community/salesforce/src/lib/common/crm.ts b/packages/pieces/community/salesforce/src/lib/common/crm.ts new file mode 100644 index 000000000000..9db20de90d25 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/common/crm.ts @@ -0,0 +1,186 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { isNil, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { callSalesforceApi, querySalesforceApi } from '.'; +import { QueryResult, salesforceUtils } from './utils'; + +function buildBody({ fields, additionalFields }: { fields: Record; additionalFields: unknown }): Record { + return { + ...salesforceUtils.parseJsonObject({ value: additionalFields, fieldName: 'Additional Fields' }), + ...salesforceUtils.compact(Object.fromEntries(Object.entries(fields).filter(([key]) => /^[A-Z]/.test(key)))), + }; +} + +async function createRecord({ auth, object, fields, additionalFields }: RecordWriteParams & { object: string }) { + const response = await callSalesforceApi<{ id: string; success: boolean }>( + HttpMethod.POST, + auth, + `/services/data/v56.0/sobjects/${object}`, + buildBody({ fields, additionalFields }) + ); + return { id: response.body.id, success: response.body.success }; +} + +async function updateRecord({ auth, object, recordId, fields, additionalFields }: RecordWriteParams & { object: string; recordId: string }) { + const id = salesforceUtils.assertId({ value: recordId, fieldName: 'Record ID' }); + const body = buildBody({ fields, additionalFields }); + const updatedFields = Object.keys(body); + if (updatedFields.length === 0) { + throw new Error('Provide at least one field to update.'); + } + await callSalesforceApi(HttpMethod.PATCH, auth, `/services/data/v56.0/sobjects/${object}/${id}`, body); + return { id, success: true, updated_fields: updatedFields }; +} + +async function query({ auth, soql }: { auth: OAuth2PropertyValue; soql: string }) { + const response = await querySalesforceApi>(HttpMethod.GET, auth, soql); + return response.body; +} + +async function searchRecords({ auth, object, fields, conditions, orderBy, limit }: SearchParams) { + const where = conditions.filter((condition): condition is string => typeof condition === 'string'); + const whereClause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''; + const soql = `SELECT ${fields.join(', ')} FROM ${object}${whereClause} ORDER BY ${orderBy} LIMIT ${clampLimit(limit)}`; + return salesforceUtils.formatQueryResult(await query({ auth, soql })); +} + +function equals({ field, value }: ConditionParams): string | undefined { + return isBlank(value) ? undefined : `${field} = '${salesforceUtils.escapeSoqlValue(value.trim())}'`; +} + +function contains({ field, value }: ConditionParams): string | undefined { + return isBlank(value) ? undefined : `${field} LIKE '%${salesforceUtils.escapeSoqlLike(value.trim())}%'`; +} + +function idEquals({ field, value }: ConditionParams): string | undefined { + return isBlank(value) ? undefined : `${field} = '${salesforceUtils.assertId({ value, fieldName: field })}'`; +} + +function booleanEquals({ field, value }: ConditionParams): string | undefined { + if (isNil(value) || String(value) === '') { + return undefined; + } + return `${field} = ${String(value).toLowerCase() === 'true' ? 'TRUE' : 'FALSE'}`; +} + +function numberCompare({ field, operator, value }: ConditionParams & { operator: '>=' | '<=' }): string | undefined { + if (isNil(value)) { + return undefined; + } + if (!Number.isFinite(value)) { + throw new Error(`${field} must be a number.`); + } + return `${field} ${operator} ${value}`; +} + +function dateCompare({ field, operator, value }: ConditionParams & { operator: '>=' | '<=' }): string | undefined { + const date = assertDate({ value, fieldName: field }); + return isNil(date) ? undefined : `${field} ${operator} ${date}`; +} + +function assertDate({ value, fieldName }: { value: string | undefined | null; fieldName: string }): string | undefined { + if (isBlank(value)) { + return undefined; + } + const trimmed = value.trim(); + if (!DATE_PATTERN.test(trimmed) || Number.isNaN(Date.parse(trimmed))) { + throw new Error(`${fieldName} must be a date in YYYY-MM-DD format.`); + } + return trimmed; +} + +function exactlyOne({ values, fieldNames }: { values: (string | undefined | null)[]; fieldNames: string }): number { + const provided = values.map((value, index) => (isBlank(value) ? -1 : index)).filter((index) => index >= 0); + if (provided.length !== 1) { + throw new Error(`Provide exactly one of ${fieldNames}.`); + } + return provided[0]; +} + +async function getClosedTaskStatus({ auth }: { auth: OAuth2PropertyValue }): Promise { + const result = await query({ + auth, + soql: 'SELECT ApiName, MasterLabel FROM TaskStatus WHERE IsClosed = true ORDER BY SortOrder LIMIT 1', + }); + const record = result.records[0]; + if (!salesforceUtils.isRecord(record)) { + return 'Completed'; + } + const status = record['ApiName'] ?? record['MasterLabel']; + return typeof status === 'string' && status.length > 0 ? status : 'Completed'; +} + +function booleanFilterProp({ displayName, description }: { displayName: string; description: string }) { + return Property.StaticDropdown({ + displayName, + description, + required: false, + options: { + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + }); +} + +export const crmUtils = { + createRecord, + updateRecord, + query, + searchRecords, + equals, + contains, + idEquals, + booleanEquals, + numberCompare, + dateCompare, + assertDate, + exactlyOne, + getClosedTaskStatus, + booleanFilterProp, + additionalFieldsProp: Property.Json({ + displayName: 'Additional Fields', + description: 'Any other fields, including custom ones, as a JSON object of API name to value, e.g. {"My_Field__c": "x"}.', + required: false, + }), + limitProp: Property.Number({ + displayName: 'Limit', + description: 'Maximum records to return (default 50, max 200).', + required: false, + }), +}; + +function clampLimit(limit: number | undefined | null): number { + if (isNil(limit) || !Number.isFinite(limit)) { + return DEFAULT_LIMIT; + } + return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); +} + +function isBlank(value: string | undefined | null): value is undefined | null { + return isNil(value) || value.trim() === ''; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +type RecordWriteParams = { + auth: OAuth2PropertyValue; + fields: Record; + additionalFields: unknown; +}; + +type SearchParams = { + auth: OAuth2PropertyValue; + object: string; + fields: string[]; + conditions: (string | undefined)[]; + orderBy: string; + limit: number | undefined | null; +}; + +type ConditionParams = { + field: string; + value: T | undefined | null; +}; diff --git a/packages/pieces/community/salesforce/src/lib/common/index.ts b/packages/pieces/community/salesforce/src/lib/common/index.ts index f9f9cf8cc175..3d22ecdd6906 100644 --- a/packages/pieces/community/salesforce/src/lib/common/index.ts +++ b/packages/pieces/community/salesforce/src/lib/common/index.ts @@ -726,12 +726,15 @@ export async function callSalesforceApi( method: HttpMethod, authentication: OAuth2PropertyValue, url: string, - body: Record | undefined + body: Record | unknown[] | string | undefined, + options?: { headers?: Record; responseType?: 'arraybuffer' | 'json' | 'text' } ): Promise> { return await httpClient.sendRequest({ method: method, url: `${authentication.data['instance_url']}${url}`, body, + headers: options?.headers, + responseType: options?.responseType, authentication: { type: AuthenticationType.BEARER_TOKEN, token: authentication['access_token'], diff --git a/packages/pieces/community/salesforce/src/lib/common/records.ts b/packages/pieces/community/salesforce/src/lib/common/records.ts new file mode 100644 index 000000000000..278e2abe06b5 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/common/records.ts @@ -0,0 +1,57 @@ +import { salesforceUtils } from './utils'; + +function parseRecordList({ value, fieldName, max }: { value: unknown; fieldName: string; max: number }): Record[] { + const list = salesforceUtils.parseJsonArray({ value, fieldName }) ?? []; + if (list.length === 0) { + throw new Error(`${fieldName} must contain at least one record.`); + } + if (list.length > max) { + throw new Error(`${fieldName} can contain at most ${max} records per call.`); + } + return list.map((item, index) => { + if (!salesforceUtils.isRecord(item)) { + throw new Error(`${fieldName}[${index}] must be a JSON object.`); + } + return item; + }); +} + +function parseIdList({ value, fieldName, max }: { value: unknown; fieldName: string; max: number }): string[] { + const ids = salesforceUtils.toStringArray(value).map((id) => salesforceUtils.assertId({ value: id, fieldName })); + if (ids.length === 0) { + throw new Error(`${fieldName} must contain at least one id.`); + } + if (ids.length > max) { + throw new Error(`${fieldName} can contain at most ${max} ids per call.`); + } + return ids; +} + +function formatCompositeResults(results: CompositeResult[]) { + const rows = results.map((result, index) => ({ + index, + id: result.id ?? null, + success: result.success, + ...(result.created === undefined ? {} : { created: result.created }), + errors: (result.errors ?? []).map((error) => `${error.statusCode}: ${error.message}`).join('; '), + })); + return { + results: rows, + success_count: rows.filter((row) => row.success).length, + failure_count: rows.filter((row) => !row.success).length, + }; +} + +export const recordsUtils = { + parseRecordList, + parseIdList, + formatCompositeResults, + MAX_COMPOSITE_RECORDS: 200, +}; + +export type CompositeResult = { + id?: string | null; + success: boolean; + created?: boolean; + errors?: { statusCode: string; message: string; fields?: string[] }[]; +}; diff --git a/packages/pieces/community/salesforce/src/lib/common/report.ts b/packages/pieces/community/salesforce/src/lib/common/report.ts new file mode 100644 index 000000000000..8a5b6413734e --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/common/report.ts @@ -0,0 +1,129 @@ +function transformReportToRows(report: SalesforceReportResponse): { + reportName: string; + reportId: string; + totalRows: number; + columns: string[]; + rows: Record[]; +} { + const detailColumns = report.reportMetadata?.detailColumns ?? []; + const detailColumnInfo = report.reportExtendedMetadata?.detailColumnInfo ?? {}; + const groupingsDown = report.reportMetadata?.groupingsDown ?? []; + const groupingColumnInfo = report.reportExtendedMetadata?.groupingColumnInfo ?? {}; + const factMap = report.factMap ?? {}; + + const columnLabels = detailColumns.map((col) => detailColumnInfo[col]?.label ?? col); + const groupingLabels = groupingsDown.map((g) => groupingColumnInfo[g.name]?.label ?? g.name); + const groupingValues = extractGroupingValues({ groupings: report.groupingsDown?.groupings ?? [], depth: 0 }); + + const allRows = Object.entries(factMap).flatMap(([factMapKey, factMapEntry]) => { + if (!factMapEntry?.rows) { + return []; + } + const groupContext = resolveGroupingContext({ factMapKey, groupingValues, groupingLabels }); + return factMapEntry.rows.map((row) => ({ + ...groupContext, + ...Object.fromEntries( + (row.dataCells ?? []).map((cell, i) => { + const value = cell.label ?? cell.value; + return [columnLabels[i] ?? `Column_${i}`, value === '-' || value === '--' ? '' : value]; + }), + ), + })); + }); + + return { + reportName: report.attributes?.reportName ?? report.reportMetadata?.name ?? 'Unknown Report', + reportId: report.attributes.reportId ?? 'Unknown Report', + totalRows: allRows.length, + columns: [...groupingLabels, ...columnLabels], + rows: allRows, + }; +} + +export const reportUtils = { + transformReportToRows, +}; + +function extractGroupingValues({ + groupings, + depth, +}: { + groupings: ReportGrouping[]; + depth: number; +}): Record { + return groupings.reduce>( + (acc, grouping) => ({ + ...acc, + [grouping.key]: { label: grouping.label, depth }, + ...(grouping.groupings && grouping.groupings.length > 0 + ? extractGroupingValues({ groupings: grouping.groupings, depth: depth + 1 }) + : {}), + }), + {}, + ); +} + +function resolveGroupingContext({ + factMapKey, + groupingValues, + groupingLabels, +}: { + factMapKey: string; + groupingValues: Record; + groupingLabels: string[]; +}): Record { + const [rowPart] = factMapKey.split('!'); + if (rowPart === 'T' || !rowPart) { + return {}; + } + const rowKeys = rowPart.split('_'); + const cumulativeKeys = rowKeys.map((_, depth) => rowKeys.slice(0, depth + 1).join('_')); + return Object.fromEntries( + cumulativeKeys.flatMap((key, depth) => { + const groupInfo = groupingValues[key]; + return groupInfo ? [[groupingLabels[depth] ?? `Group_${depth}`, groupInfo.label]] : []; + }), + ); +} + +type ReportGrouping = { + key: string; + label: string; + value: unknown; + groupings?: ReportGrouping[]; +}; + +export type SalesforceReportResponse = { + attributes: { + reportId: string; + reportName: string; + status?: string; + }; + reportMetadata: { + detailColumns: string[]; + name: string; + reportFormat: string; + aggregates: string[]; + groupingsDown: { + name: string; + sortOrder: string; + dateGranularity: string; + column: string; + }[]; + }; + reportExtendedMetadata: { + detailColumnInfo: Record; + groupingColumnInfo: Record; + aggregateColumnInfo: Record; + }; + factMap: Record< + string, + { + rows: { dataCells: { label: string; value: unknown }[] }[]; + aggregates: { label: string; value: unknown }[]; + } + >; + groupingsDown: { + groupings: ReportGrouping[]; + }; +}; diff --git a/packages/pieces/community/salesforce/src/lib/common/utils.ts b/packages/pieces/community/salesforce/src/lib/common/utils.ts new file mode 100644 index 000000000000..ded6a998cc71 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/common/utils.ts @@ -0,0 +1,131 @@ +import { isNil } from '@activepieces/pieces-framework'; + +function escapeSoqlValue(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +function escapeSoqlLike(value: string): string { + return escapeSoqlValue(value).replace(/%/g, '\\%').replace(/_/g, '\\_'); +} + +function escapeSosl(value: string): string { + return value.replace(/[?&|!{}[\]()^~*:\\"'+-]/g, (char) => `\\${char}`); +} + +function assertApiName({ value, fieldName }: { value: string; fieldName: string }): string { + const trimmed = value.trim(); + if (!API_NAME_PATTERN.test(trimmed)) { + throw new Error(`${fieldName} must be a Salesforce API name such as Account or My_Field__c.`); + } + return trimmed; +} + +function assertId({ value, fieldName }: { value: string; fieldName: string }): string { + const trimmed = value.trim(); + if (!ID_PATTERN.test(trimmed)) { + throw new Error(`${fieldName} must be a 15- or 18-character Salesforce record id.`); + } + return trimmed; +} + +function parseJsonObject({ value, fieldName }: { value: unknown; fieldName: string }): Record | undefined { + if (isNil(value) || value === '') { + return undefined; + } + const parsed = typeof value === 'string' ? parseJson({ value, fieldName }) : value; + if (!isRecord(parsed)) { + throw new Error(`${fieldName} must be a JSON object.`); + } + return parsed; +} + +function parseJsonArray({ value, fieldName }: { value: unknown; fieldName: string }): unknown[] | undefined { + if (isNil(value) || value === '') { + return undefined; + } + const parsed = typeof value === 'string' ? parseJson({ value, fieldName }) : value; + if (!Array.isArray(parsed)) { + throw new Error(`${fieldName} must be a JSON array.`); + } + return parsed; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => String(item).trim()).filter((item) => item.length > 0); +} + +function compact(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function cleanRecord(record: unknown): unknown { + if (Array.isArray(record)) { + return record.map(cleanRecord); + } + if (!isRecord(record)) { + return record; + } + return Object.fromEntries( + Object.entries(record) + .filter(([key]) => key !== 'attributes') + .map(([key, value]) => [key, cleanRecord(value)]), + ); +} + +function formatQueryResult(result: QueryResult) { + return { + records: result.records.map(cleanRecord), + count: result.records.length, + total_size: result.totalSize, + done: result.done, + next_records_url: result.nextRecordsUrl ?? null, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const salesforceUtils = { + escapeSoqlValue, + escapeSoqlLike, + escapeSosl, + assertApiName, + assertId, + parseJsonObject, + parseJsonArray, + toStringArray, + compact, + cleanRecord, + formatQueryResult, + isRecord, +}; + +function parseJson({ value, fieldName }: { value: string; fieldName: string }): unknown { + try { + return JSON.parse(value); + } catch { + throw new Error(`${fieldName} must be valid JSON.`); + } +} + +const API_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; +const ID_PATTERN = /^[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?$/; + +export type SalesforceRecord = Record; + +export type QueryResult = { + totalSize: number; + done: boolean; + nextRecordsUrl?: string; + records: T[]; +}; diff --git a/packages/pieces/community/salesforce/src/lib/output-schemas.ts b/packages/pieces/community/salesforce/src/lib/output-schemas.ts new file mode 100644 index 000000000000..d5a8af36dab3 --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/output-schemas.ts @@ -0,0 +1,1167 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const createResultFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, +]; + +const updateResultFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'updated_fields', label: 'Updated Fields' }, +]; + +const queryPageFields: OutputSchema['fields'] = [ + { key: 'count', label: 'Records Returned', format: 'number' }, + { key: 'total_size', label: 'Total Matching Records', format: 'number' }, + { key: 'done', label: 'All Records Returned', format: 'boolean' }, + { key: 'next_records_url', label: 'Next Records URL' }, +]; + +const compositeResultItemFields: OutputSchema['fields'] = [ + { key: 'index', label: 'Index', format: 'number' }, + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'errors', label: 'Errors' }, +]; + +const compositeCountFields: OutputSchema['fields'] = [ + { key: 'success_count', label: 'Succeeded', format: 'number' }, + { key: 'failure_count', label: 'Failed', format: 'number' }, +]; + +const bulkJobSummaryFields: OutputSchema['fields'] = [ + { key: 'job_id', label: 'Job ID' }, + { key: 'state', label: 'State' }, + { key: 'object', label: 'Object' }, + { key: 'operation', label: 'Operation' }, +]; + +const reportRowsFields: OutputSchema['fields'] = [ + { key: 'reportName', label: 'Report Name' }, + { key: 'reportId', label: 'Report ID' }, + { key: 'totalRows', label: 'Total Rows', format: 'number' }, + { key: 'columns', label: 'Columns' }, + { key: 'rows', label: 'Rows' }, +]; + +const accountNameFields: OutputSchema['fields'] = [{ key: 'Name', label: 'Account Name' }]; + +const accountRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Account ID' }, + { key: 'Name', label: 'Name' }, + { key: 'Phone', label: 'Phone' }, + { key: 'Website', label: 'Website', format: 'url' }, + { key: 'Industry', label: 'Industry' }, + { key: 'Type', label: 'Type' }, + { key: 'BillingCity', label: 'Billing City' }, + { key: 'BillingState', label: 'Billing State' }, + { key: 'BillingCountry', label: 'Billing Country' }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'ParentId', label: 'Parent Account ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, +]; + +const contactRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Contact ID' }, + { key: 'FirstName', label: 'First Name' }, + { key: 'LastName', label: 'Last Name' }, + { key: 'Name', label: 'Full Name' }, + { key: 'Email', label: 'Email', format: 'email' }, + { key: 'Phone', label: 'Phone' }, + { key: 'MobilePhone', label: 'Mobile Phone' }, + { key: 'Title', label: 'Title' }, + { key: 'Department', label: 'Department' }, + { key: 'AccountId', label: 'Account ID' }, + { key: 'Account', label: 'Account', children: accountNameFields }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, +]; + +const leadRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Lead ID' }, + { key: 'FirstName', label: 'First Name' }, + { key: 'LastName', label: 'Last Name' }, + { key: 'Name', label: 'Full Name' }, + { key: 'Company', label: 'Company' }, + { key: 'Email', label: 'Email', format: 'email' }, + { key: 'Phone', label: 'Phone' }, + { key: 'Title', label: 'Title' }, + { key: 'Status', label: 'Status' }, + { key: 'LeadSource', label: 'Lead Source' }, + { key: 'Industry', label: 'Industry' }, + { key: 'IsConverted', label: 'Converted', format: 'boolean' }, + { key: 'ConvertedContactId', label: 'Converted Contact ID' }, + { key: 'ConvertedAccountId', label: 'Converted Account ID' }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, +]; + +const opportunityRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Opportunity ID' }, + { key: 'Name', label: 'Name' }, + { key: 'AccountId', label: 'Account ID' }, + { key: 'Account', label: 'Account', children: accountNameFields }, + { key: 'StageName', label: 'Stage' }, + { key: 'Amount', label: 'Amount', format: 'number' }, + { key: 'Probability', label: 'Probability (%)', format: 'number' }, + { key: 'CloseDate', label: 'Close Date', format: 'date' }, + { key: 'IsClosed', label: 'Closed', format: 'boolean' }, + { key: 'IsWon', label: 'Won', format: 'boolean' }, + { key: 'Type', label: 'Type' }, + { key: 'LeadSource', label: 'Lead Source' }, + { key: 'NextStep', label: 'Next Step' }, + { key: 'Pricebook2Id', label: 'Price Book ID' }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, +]; + +const pricebookRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Price Book ID' }, + { key: 'Name', label: 'Name' }, + { key: 'IsActive', label: 'Active', format: 'boolean' }, + { key: 'IsStandard', label: 'Standard', format: 'boolean' }, + { key: 'Description', label: 'Description' }, +]; + +const pricebookEntryRecordFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Price Book Entry ID' }, + { key: 'Pricebook2Id', label: 'Price Book ID' }, + { key: 'Product2Id', label: 'Product ID' }, + { key: 'Product2', label: 'Product', children: [{ key: 'Name', label: 'Product Name' }] }, + { key: 'ProductCode', label: 'Product Code' }, + { key: 'UnitPrice', label: 'Unit Price', format: 'number' }, + { key: 'IsActive', label: 'Active', format: 'boolean' }, +]; + +const bulkJobFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Job ID' }, + { key: 'state', label: 'State' }, + { key: 'object', label: 'Object' }, + { key: 'operation', label: 'Operation' }, + { key: 'numberRecordsProcessed', label: 'Records Processed', format: 'number' }, + { key: 'numberRecordsFailed', label: 'Records Failed', format: 'number' }, + { key: 'errorMessage', label: 'Error Message' }, + { key: 'createdDate', label: 'Created Date', format: 'datetime' }, + { key: 'systemModstamp', label: 'Last Modified Date', format: 'datetime' }, + { key: 'totalProcessingTime', label: 'Total Processing Time (ms)', format: 'number' }, +]; + +const sobjectEnvelopeFields: OutputSchema['fields'] = [ + { key: 'Id', label: 'Record ID' }, + { key: 'attributes', label: 'Record Info', children: [{ key: 'type', label: 'Object' }] }, +]; + +const addressFields: OutputSchema['fields'] = [ + { key: 'street', label: 'Street' }, + { key: 'city', label: 'City' }, + { key: 'state', label: 'State' }, + { key: 'postalCode', label: 'Postal Code' }, + { key: 'country', label: 'Country' }, +]; + +const rawQueryFields: OutputSchema['fields'] = [ + { key: 'totalSize', label: 'Total Matching Records', format: 'number' }, + { key: 'done', label: 'All Records Returned', format: 'boolean' }, + { key: 'records', label: 'Records' }, +]; + +export const addContactToCampaignMemberOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Campaign Member ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'campaign_id', label: 'Campaign ID' }, + { key: 'contact_id', label: 'Contact ID' }, + ], +}; + +export const addLeadToCampaignMemberOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Campaign Member ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'campaign_id', label: 'Campaign ID' }, + { key: 'lead_id', label: 'Lead ID' }, + ], +}; + +export const addOpportunityProductOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Opportunity Product ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'opportunity_id', label: 'Opportunity ID' }, + { key: 'pricebook_entry_id', label: 'Price Book Entry ID' }, + { key: 'quantity', label: 'Quantity', format: 'number' }, + ], +}; + +export const applyLeadAssignmentRulesOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Lead ID' }, + { key: 'previous_owner_id', label: 'Previous Owner ID' }, + { key: 'owner_id', label: 'Owner ID' }, + { key: 'reassigned', label: 'Reassigned', format: 'boolean' }, + ], +}; + +export const completeTaskOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Task ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'status', label: 'Status' }, + { key: 'updated_fields', label: 'Updated Fields' }, + ], +}; + +export const createAccountOutputSchema: OutputSchema = { + fields: [...createResultFields, { key: 'name', label: 'Account Name' }], +}; + +export const createCampaignOutputSchema: OutputSchema = { + fields: [...createResultFields, { key: 'name', label: 'Campaign Name' }], +}; + +export const createSfContactOutputSchema: OutputSchema = { + fields: [ + ...createResultFields, + { key: 'last_name', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + ], +}; + +export const createSfLeadOutputSchema: OutputSchema = { + fields: [ + ...createResultFields, + { key: 'last_name', label: 'Last Name' }, + { key: 'company', label: 'Company' }, + ], +}; + +export const createSfNoteOutputSchema: OutputSchema = { + fields: [ + ...createResultFields, + { key: 'parent_id', label: 'Parent Record ID' }, + { key: 'title', label: 'Title' }, + ], +}; + +export const createSfOpportunityOutputSchema: OutputSchema = { + fields: [ + ...createResultFields, + { key: 'name', label: 'Opportunity Name' }, + { key: 'stage', label: 'Stage' }, + { key: 'close_date', label: 'Close Date', format: 'date' }, + ], +}; + +export const createSfTaskOutputSchema: OutputSchema = { + fields: [...createResultFields, { key: 'subject', label: 'Subject' }], +}; + +export const createSobjectRecordOutputSchema: OutputSchema = { + fields: [...createResultFields, { key: 'object', label: 'Object' }], +}; + +export const logCallOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Task ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'subject', label: 'Subject' }, + { key: 'status', label: 'Status' }, + { key: 'activity_date', label: 'Call Date', format: 'date' }, + ], +}; + +export const logEmailActivityOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Email Message ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'recipient_id', label: 'Recipient ID' }, + { key: 'related_record_id', label: 'Related Record ID' }, + { key: 'subject', label: 'Subject' }, + ], +}; + +export const updateRecordOutputSchema: OutputSchema = { fields: updateResultFields }; + +export const upsertRecordByExternalIdOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'created', label: 'Created', format: 'boolean' }, + { key: 'external_id_field', label: 'External ID Field' }, + { key: 'external_id_value', label: 'External ID Value' }, + { key: 'updated_fields', label: 'Updated Fields' }, + ], +}; + +export const deletedRecordOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Deleted ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const compositeBatchOutputSchema: OutputSchema = { + fields: [ + { key: 'results', label: 'Results', labelKey: 'id', listItems: compositeResultItemFields }, + ...compositeCountFields, + ], +}; + +export const upsertRecordsBatchOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Results', + labelKey: 'id', + listItems: [...compositeResultItemFields, { key: 'created', label: 'Created', format: 'boolean' }], + }, + ...compositeCountFields, + ], +}; + +export const createRecordTreeOutputSchema: OutputSchema = { + fields: [ + { key: 'has_errors', label: 'Has Errors', format: 'boolean' }, + { + key: 'results', + label: 'Results', + labelKey: 'reference_id', + listItems: [ + { key: 'reference_id', label: 'Reference ID' }, + { key: 'id', label: 'Record ID' }, + { key: 'errors', label: 'Errors' }, + ], + }, + ], +}; + +export const recordOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Record ID' }, + { key: 'record', label: 'Record Fields', value: '', dynamicKey: true }, + ], +}; + +export const getRecordsBatchOutputSchema: OutputSchema = { + fields: [ + { + key: 'records', + label: 'Records', + labelKey: 'requested_id', + listItems: [ + { key: 'requested_id', label: 'Requested ID' }, + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'record', label: 'Record', dynamicKey: true }, + ], + }, + { key: 'count', label: 'Records Requested', format: 'number' }, + { key: 'found_count', label: 'Records Found', format: 'number' }, + ], +}; + +export const getQuickActionDefaultsOutputSchema: OutputSchema = { + fields: [{ key: 'defaults', label: 'Default Field Values', value: '', dynamicKey: true }], +}; + +export const soqlQueryOutputSchema: OutputSchema = { + fields: [{ key: 'records', label: 'Records' }, ...queryPageFields], +}; + +export const searchAccountsOutputSchema: OutputSchema = { + fields: [{ key: 'records', label: 'Accounts', labelKey: 'Name', listItems: accountRecordFields }, ...queryPageFields], +}; + +export const searchContactsOutputSchema: OutputSchema = { + fields: [{ key: 'records', label: 'Contacts', labelKey: 'Name', listItems: contactRecordFields }, ...queryPageFields], +}; + +export const searchLeadsOutputSchema: OutputSchema = { + fields: [{ key: 'records', label: 'Leads', labelKey: 'Name', listItems: leadRecordFields }, ...queryPageFields], +}; + +export const searchOpportunitiesOutputSchema: OutputSchema = { + fields: [ + { key: 'records', label: 'Opportunities', labelKey: 'Name', listItems: opportunityRecordFields }, + ...queryPageFields, + ], +}; + +export const listDashboardsOutputSchema: OutputSchema = { + fields: [ + { + key: 'records', + label: 'Dashboards', + labelKey: 'Title', + listItems: [ + { key: 'Id', label: 'Dashboard ID' }, + { key: 'Title', label: 'Title' }, + { key: 'DeveloperName', label: 'Developer Name' }, + { key: 'FolderName', label: 'Folder Name' }, + { key: 'Description', label: 'Description' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], + }, + ...queryPageFields, + ], +}; + +export const listEmailTemplatesOutputSchema: OutputSchema = { + fields: [ + { + key: 'records', + label: 'Email Templates', + labelKey: 'Name', + listItems: [ + { key: 'Id', label: 'Template ID' }, + { key: 'Name', label: 'Name' }, + { key: 'DeveloperName', label: 'Developer Name' }, + { key: 'Subject', label: 'Subject' }, + { key: 'FolderId', label: 'Folder ID' }, + { key: 'TemplateType', label: 'Template Type' }, + { key: 'IsActive', label: 'Active', format: 'boolean' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], + }, + ...queryPageFields, + ], +}; + +export const listReportsOutputSchema: OutputSchema = { + fields: [ + { + key: 'records', + label: 'Reports', + labelKey: 'Name', + listItems: [ + { key: 'Id', label: 'Report ID' }, + { key: 'Name', label: 'Name' }, + { key: 'DeveloperName', label: 'Developer Name' }, + { key: 'FolderName', label: 'Folder Name' }, + { key: 'Format', label: 'Format' }, + { key: 'Description', label: 'Description' }, + { key: 'LastRunDate', label: 'Last Run Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], + }, + ...queryPageFields, + ], +}; + +export const listPricebooksOutputSchema: OutputSchema = { + fields: [ + { + key: 'pricebooks', + label: 'Price Books', + children: [ + { key: 'records', label: 'Price Books', labelKey: 'Name', listItems: pricebookRecordFields }, + ...queryPageFields, + ], + }, + { + key: 'entries', + label: 'Price Book Entries', + children: [ + { key: 'records', label: 'Entries', labelKey: 'ProductCode', listItems: pricebookEntryRecordFields }, + ...queryPageFields, + ], + }, + ], +}; + +export const searchRecordsSoslOutputSchema: OutputSchema = { + fields: [ + { key: 'records', label: 'Records' }, + { key: 'count', label: 'Records Returned', format: 'number' }, + ], +}; + +export const getListViewRecordsOutputSchema: OutputSchema = { + fields: [ + { key: 'records', label: 'Records' }, + { key: 'count', label: 'Records Returned', format: 'number' }, + { key: 'size', label: 'List View Size', format: 'number' }, + { key: 'done', label: 'All Records Returned', format: 'boolean' }, + ], +}; + +export const getListViewMetadataOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'List View ID' }, + { key: 'sobject_type', label: 'Object' }, + { key: 'query', label: 'SOQL Query' }, + { + key: 'columns', + label: 'Columns', + labelKey: 'label', + listItems: [ + { key: 'field_name_or_path', label: 'Field' }, + { key: 'label', label: 'Label' }, + { key: 'sortable', label: 'Sortable', format: 'boolean' }, + { key: 'type', label: 'Type' }, + ], + }, + { + key: 'order_by', + label: 'Order By', + labelKey: 'fieldNameOrPath', + listItems: [ + { key: 'fieldNameOrPath', label: 'Field' }, + { key: 'sortDirection', label: 'Sort Direction' }, + { key: 'nullsPosition', label: 'Nulls Position' }, + ], + }, + { + key: 'where_condition', + label: 'Filter', + children: [ + { key: 'conjunction', label: 'Conjunction' }, + { key: 'conditions', label: 'Conditions' }, + ], + }, + ], +}; + +export const listListViewsOutputSchema: OutputSchema = { + fields: [ + { + key: 'list_views', + label: 'List Views', + labelKey: 'label', + listItems: [ + { key: 'id', label: 'List View ID' }, + { key: 'label', label: 'Label' }, + { key: 'developer_name', label: 'Developer Name' }, + { key: 'soql_compatible', label: 'SOQL Compatible', format: 'boolean' }, + { key: 'results_url', label: 'Results URL' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const describeObjectOutputSchema: OutputSchema = { + fields: [ + { key: 'name', label: 'API Name' }, + { key: 'label', label: 'Label' }, + { key: 'custom', label: 'Custom', format: 'boolean' }, + { key: 'key_prefix', label: 'Key Prefix' }, + { + key: 'fields', + label: 'Fields', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'API Name' }, + { key: 'label', label: 'Label' }, + { key: 'type', label: 'Type' }, + { key: 'length', label: 'Length', format: 'number' }, + { key: 'nillable', label: 'Nillable', format: 'boolean' }, + { key: 'createable', label: 'Createable', format: 'boolean' }, + { key: 'updateable', label: 'Updateable', format: 'boolean' }, + { key: 'custom', label: 'Custom', format: 'boolean' }, + { key: 'external_id', label: 'External ID', format: 'boolean' }, + { key: 'reference_to', label: 'References' }, + { + key: 'picklist_values', + label: 'Picklist Values', + labelKey: 'label', + listItems: [ + { key: 'value', label: 'Value' }, + { key: 'label', label: 'Label' }, + ], + }, + ], + }, + { + key: 'child_relationships', + label: 'Child Relationships', + labelKey: 'relationship_name', + listItems: [ + { key: 'relationship_name', label: 'Relationship Name' }, + { key: 'child_object', label: 'Child Object' }, + { key: 'field', label: 'Field' }, + ], + }, + { + key: 'record_types', + label: 'Record Types', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'developer_name', label: 'Developer Name' }, + { key: 'record_type_id', label: 'Record Type ID' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'master', label: 'Master', format: 'boolean' }, + ], + }, + ], +}; + +export const listObjectsOutputSchema: OutputSchema = { + fields: [ + { + key: 'objects', + label: 'Objects', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'API Name' }, + { key: 'label', label: 'Label' }, + { key: 'custom', label: 'Custom', format: 'boolean' }, + { key: 'queryable', label: 'Queryable', format: 'boolean' }, + { key: 'createable', label: 'Createable', format: 'boolean' }, + { key: 'updateable', label: 'Updateable', format: 'boolean' }, + { key: 'deletable', label: 'Deletable', format: 'boolean' }, + { key: 'key_prefix', label: 'Key Prefix' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const downloadFileOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'File', format: 'url' }, + { key: 'file_name', label: 'File Name' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'mime_type', label: 'MIME Type' }, + ], +}; + +export const getFileInfoOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Content Document ID' }, + { key: 'title', label: 'Title' }, + { key: 'name', label: 'Name' }, + { key: 'file_extension', label: 'File Extension' }, + { key: 'file_type', label: 'File Type' }, + { key: 'content_size', label: 'Size', format: 'filesize' }, + { key: 'mime_type', label: 'MIME Type' }, + { key: 'owner_id', label: 'Owner ID' }, + { key: 'owner_name', label: 'Owner Name' }, + { key: 'created_date', label: 'Created Date', format: 'datetime' }, + { key: 'modified_date', label: 'Modified Date', format: 'datetime' }, + { key: 'version_number', label: 'Version Number' }, + { key: 'download_url', label: 'Download Path' }, + ], +}; + +export const uploadFileOutputSchema: OutputSchema = { + fields: [ + { key: 'content_version_id', label: 'Content Version ID' }, + { key: 'content_document_id', label: 'Content Document ID' }, + { key: 'title', label: 'Title' }, + { key: 'record_id', label: 'Linked Record ID' }, + ], +}; + +export const bulkJobCreatedOutputSchema: OutputSchema = { fields: bulkJobSummaryFields }; + +export const bulkJobOutputSchema: OutputSchema = { fields: bulkJobFields }; + +export const getBulkIngestResultsOutputSchema: OutputSchema = { + fields: [ + { key: 'rows', label: 'Rows' }, + { key: 'count', label: 'Row Count', format: 'number' }, + ], +}; + +export const getBulkQueryResultsOutputSchema: OutputSchema = { + fields: [ + { key: 'rows', label: 'Rows' }, + { key: 'count', label: 'Row Count', format: 'number' }, + { key: 'next_locator', label: 'Next Locator' }, + ], +}; + +export const getCurrentUserOutputSchema: OutputSchema = { + fields: [ + { key: 'user_id', label: 'User ID' }, + { key: 'organization_id', label: 'Organization ID' }, + { key: 'username', label: 'Username' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'locale', label: 'Locale' }, + { key: 'zoneinfo', label: 'Time Zone' }, + { key: 'user_type', label: 'User Type' }, + ], +}; + +export const getDashboardOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Dashboard ID' }, + { key: 'name', label: 'Name' }, + { key: 'developer_name', label: 'Developer Name' }, + { key: 'folder_id', label: 'Folder ID' }, + { + key: 'components', + label: 'Components', + labelKey: 'header', + listItems: [ + { key: 'id', label: 'Component ID' }, + { key: 'header', label: 'Header' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Type' }, + { key: 'visualization_type', label: 'Visualization Type' }, + { key: 'report_id', label: 'Report ID' }, + ], + }, + { + key: 'filters', + label: 'Filters', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { + key: 'options', + label: 'Options', + labelKey: 'alias', + listItems: [ + { key: 'id', label: 'Option ID' }, + { key: 'alias', label: 'Alias' }, + { key: 'operation', label: 'Operation' }, + { key: 'value', label: 'Value' }, + ], + }, + ], + }, + ], +}; + +export const getOrgLimitsOutputSchema: OutputSchema = { + fields: [ + { + key: 'limits', + label: 'Limits', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Limit' }, + { key: 'max', label: 'Max', format: 'number' }, + { key: 'remaining', label: 'Remaining', format: 'number' }, + { key: 'used', label: 'Used', format: 'number' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const getRecordCountsOutputSchema: OutputSchema = { + fields: [ + { + key: 'counts', + label: 'Record Counts', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Object' }, + { key: 'count', label: 'Record Count', format: 'number' }, + ], + }, + { key: 'count', label: 'Objects Counted', format: 'number' }, + ], +}; + +export const getReportOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Report ID' }, + { key: 'name', label: 'Name' }, + { key: 'developer_name', label: 'Developer Name' }, + { key: 'format', label: 'Format' }, + { key: 'report_type', label: 'Report Type' }, + { key: 'report_type_label', label: 'Report Type Label' }, + { key: 'detail_columns', label: 'Detail Columns' }, + { key: 'groupings_down', label: 'Row Groupings' }, + { key: 'filters', label: 'Filters' }, + { key: 'filter_logic', label: 'Filter Logic' }, + { + key: 'columns', + label: 'Columns', + labelKey: 'label', + listItems: [ + { key: 'api_name', label: 'API Name' }, + { key: 'label', label: 'Label' }, + { key: 'data_type', label: 'Data Type' }, + ], + }, + ], +}; + +export const runReportSyncOutputSchema: OutputSchema = { fields: reportRowsFields }; + +export const getReportInstanceOutputSchema: OutputSchema = { + fields: [ + { key: 'instance_id', label: 'Instance ID' }, + { key: 'status', label: 'Status' }, + ...reportRowsFields, + ], +}; + +export const runReportAsyncOutputSchema: OutputSchema = { + fields: [ + { key: 'instance_id', label: 'Instance ID' }, + { key: 'status', label: 'Status' }, + { key: 'report_id', label: 'Report ID' }, + { key: 'request_date', label: 'Request Date', format: 'datetime' }, + ], +}; + +export const getUpdatedRecordIdsOutputSchema: OutputSchema = { + fields: [ + { key: 'ids', label: 'Record IDs' }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'latest_date_covered', label: 'Latest Date Covered', format: 'datetime' }, + ], +}; + +export const listInvocableActionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'actions', + label: 'Actions', + labelKey: 'label', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + { key: 'type', label: 'Type' }, + { key: 'url', label: 'API Path' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const runInvocableActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Results', + labelKey: 'action_name', + listItems: [ + { key: 'action_name', label: 'Action Name' }, + { key: 'is_success', label: 'Success', format: 'boolean' }, + { key: 'errors', label: 'Errors' }, + { key: 'output_values', label: 'Output Values', dynamicKey: true }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'failed_count', label: 'Failed', format: 'number' }, + ], +}; + +export const listQuickActionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'actions', + label: 'Quick Actions', + labelKey: 'label', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'label', label: 'Label' }, + { key: 'type', label: 'Type' }, + { key: 'sobject_type', label: 'Object' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const runQuickActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'created', label: 'Created', format: 'boolean' }, + { key: 'context_id', label: 'Context Record ID' }, + { key: 'feed_item_ids', label: 'Feed Item IDs' }, + { key: 'errors', label: 'Errors' }, + ], +}; + +export const searchKnowledgeArticlesOutputSchema: OutputSchema = { + fields: [ + { + key: 'articles', + label: 'Articles', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'Article ID' }, + { key: 'article_number', label: 'Article Number' }, + { key: 'title', label: 'Title' }, + { key: 'summary', label: 'Summary' }, + { key: 'url_name', label: 'URL Name' }, + { key: 'last_published_date', label: 'Last Published Date', format: 'datetime' }, + { key: 'view_count', label: 'View Count', format: 'number' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const sendEmailMessageOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'email_addresses', label: 'Email Addresses' }, + { key: 'recipient_id', label: 'Recipient ID' }, + { key: 'related_record_id', label: 'Related Record ID' }, + { key: 'subject', label: 'Subject' }, + ], +}; + +export const sobjectCreateResponseOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'errors', label: 'Errors' }, + ], +}; + +export const successOutputSchema: OutputSchema = { + fields: [{ key: 'success', label: 'Success', format: 'boolean' }], +}; + +export const exportReportOutputSchema: OutputSchema = { + fields: [{ key: 'file', label: 'Report File', value: '', format: 'url' }], +}; + +export const findChildRecordsOutputSchema: OutputSchema = { + fields: [ + { key: 'count', label: 'Records Returned', format: 'number' }, + { + key: 'records', + label: 'Records', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Record ID' }, + { key: 'apiName', label: 'Object' }, + { key: 'fields', label: 'Fields', dynamicKey: true }, + { key: 'lastModifiedById', label: 'Last Modified By ID' }, + { key: 'lastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + { key: 'recordTypeId', label: 'Record Type ID' }, + ], + }, + { key: 'nextPageToken', label: 'Next Page Token' }, + { key: 'nextPageUrl', label: 'Next Page URL' }, + ], +}; + +export const rawQueryOutputSchema: OutputSchema = { fields: rawQueryFields }; + +export const runQueryOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'HTTP Status', format: 'number' }, + { key: 'body', label: 'Result', children: rawQueryFields }, + ], +}; + +export const getRecordAttachmentsOutputSchema: OutputSchema = { + fields: [ + { + key: 'classic_attachments', + label: 'Classic Attachments', + labelKey: 'Name', + listItems: [ + { key: 'Id', label: 'Attachment ID' }, + { key: 'Name', label: 'Name' }, + { key: 'BodyLength', label: 'Size', format: 'filesize' }, + { key: 'ContentType', label: 'Content Type' }, + ], + }, + { + key: 'files', + label: 'Files', + labelKey: 'Title', + listItems: [ + { key: 'Id', label: 'Content Document ID' }, + { key: 'Title', label: 'Title' }, + ], + }, + ], +}; + +export const upsertByExternalIdOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'HTTP Status', format: 'number' }, + { + key: 'body', + label: 'Results', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Record ID' }, + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'created', label: 'Created', format: 'boolean' }, + { key: 'errors', label: 'Errors' }, + ], + }, + ], +}; + +export const upsertByExternalIdBulkOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'HTTP Status', format: 'number' }, + { + key: 'body', + label: 'Bulk Job', + children: [ + { key: 'id', label: 'Job ID' }, + { key: 'state', label: 'State' }, + { key: 'object', label: 'Object' }, + { key: 'operation', label: 'Operation' }, + { key: 'externalIdFieldName', label: 'External ID Field' }, + { key: 'numberRecordsProcessed', label: 'Records Processed', format: 'number' }, + { key: 'numberRecordsFailed', label: 'Records Failed', format: 'number' }, + { key: 'createdById', label: 'Created By ID' }, + { key: 'createdDate', label: 'Created Date', format: 'datetime' }, + { key: 'systemModstamp', label: 'Last Modified Date', format: 'datetime' }, + ], + }, + ], +}; + +export const newRecordTriggerOutputSchema: OutputSchema = { + fields: [ + ...sobjectEnvelopeFields, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + { key: 'record', label: 'Record Fields', value: '', dynamicKey: true }, + ], +}; + +export const newCaseTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Case ID' }, + { key: 'CaseNumber', label: 'Case Number' }, + { key: 'Subject', label: 'Subject' }, + { key: 'Description', label: 'Description' }, + { key: 'Status', label: 'Status' }, + { key: 'Priority', label: 'Priority' }, + { key: 'Origin', label: 'Origin' }, + { key: 'Type', label: 'Type' }, + { key: 'Reason', label: 'Reason' }, + { key: 'IsClosed', label: 'Is Closed', format: 'boolean' }, + { key: 'IsEscalated', label: 'Is Escalated', format: 'boolean' }, + { key: 'ClosedDate', label: 'Closed Date', format: 'datetime' }, + { key: 'AccountId', label: 'Account ID' }, + { key: 'ContactId', label: 'Contact ID' }, + { key: 'ContactEmail', label: 'Contact Email', format: 'email' }, + { key: 'ContactPhone', label: 'Contact Phone' }, + { key: 'ParentId', label: 'Parent Case ID' }, + { key: 'SuppliedName', label: 'Web Name' }, + { key: 'SuppliedEmail', label: 'Web Email', format: 'email' }, + { key: 'SuppliedPhone', label: 'Web Phone' }, + { key: 'SuppliedCompany', label: 'Web Company' }, + { key: 'OwnerId', label: 'Owner (Queue) ID' }, + { key: 'CreatedById', label: 'Created By ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], +}; + +export const newContactTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Contact ID' }, + { key: 'Name', label: 'Full Name' }, + { key: 'Salutation', label: 'Salutation' }, + { key: 'FirstName', label: 'First Name' }, + { key: 'LastName', label: 'Last Name' }, + { key: 'Email', label: 'Email', format: 'email' }, + { key: 'Phone', label: 'Phone' }, + { key: 'MobilePhone', label: 'Mobile Phone' }, + { key: 'Title', label: 'Title' }, + { key: 'Department', label: 'Department' }, + { key: 'AccountId', label: 'Account ID' }, + { key: 'LeadSource', label: 'Lead Source' }, + { key: 'Description', label: 'Description' }, + { key: 'MailingAddress', label: 'Mailing Address', children: addressFields }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedById', label: 'Created By ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], +}; + +export const newLeadTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Lead ID' }, + { key: 'Name', label: 'Full Name' }, + { key: 'Salutation', label: 'Salutation' }, + { key: 'FirstName', label: 'First Name' }, + { key: 'LastName', label: 'Last Name' }, + { key: 'Company', label: 'Company' }, + { key: 'Title', label: 'Title' }, + { key: 'Email', label: 'Email', format: 'email' }, + { key: 'Phone', label: 'Phone' }, + { key: 'MobilePhone', label: 'Mobile Phone' }, + { key: 'Website', label: 'Website', format: 'url' }, + { key: 'Status', label: 'Status' }, + { key: 'LeadSource', label: 'Lead Source' }, + { key: 'Industry', label: 'Industry' }, + { key: 'Rating', label: 'Rating' }, + { key: 'Description', label: 'Description' }, + { key: 'Address', label: 'Address', children: addressFields }, + { key: 'IsConverted', label: 'Converted', format: 'boolean' }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedById', label: 'Created By ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + ], +}; + +export const newUpdatedFileTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Content Document ID' }, + { key: 'Title', label: 'Title' }, + { key: 'FileType', label: 'File Type' }, + { key: 'FileExtension', label: 'File Extension' }, + { key: 'ContentSize', label: 'Size', format: 'filesize' }, + { key: 'Description', label: 'Description' }, + { key: 'LatestPublishedVersionId', label: 'Latest Version ID' }, + { key: 'PublishStatus', label: 'Publish Status' }, + { key: 'IsArchived', label: 'Archived', format: 'boolean' }, + { key: 'OwnerId', label: 'Owner ID' }, + { key: 'CreatedById', label: 'Created By ID' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + { key: 'LastModifiedDate', label: 'Last Modified Date', format: 'datetime' }, + { key: 'ContentModifiedDate', label: 'Content Modified Date', format: 'datetime' }, + { key: 'Type', label: 'Type' }, + ], +}; + +export const newCaseAttachmentTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'Attachment ID' }, + { key: 'attachment_type', label: 'Attachment Type' }, + { key: 'ParentId', label: 'Case ID' }, + { key: 'Name', label: 'Name' }, + { key: 'ContentType', label: 'Content Type' }, + { key: 'CreatedDate', label: 'Created Date', format: 'datetime' }, + ], +}; + +export const newFieldHistoryEventTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'History ID' }, + { key: 'ParentId', label: 'Record ID' }, + { key: 'Field', label: 'Field' }, + { key: 'OldValue', label: 'Old Value' }, + { key: 'NewValue', label: 'New Value' }, + { key: 'IsDeleted', label: 'Deleted', format: 'boolean' }, + { key: 'CreatedById', label: 'Changed By ID' }, + { key: 'CreatedDate', label: 'Changed Date', format: 'datetime' }, + ], +}; + +export const newOutboundMessageTriggerOutputSchema: OutputSchema = { + itemLabel: 'Notification {Id}', + fields: [ + { + key: 'notifications', + label: 'Notifications', + value: '', + listItems: [ + { key: 'Id', label: 'Notification ID' }, + { key: 'sObject', label: 'Record', dynamicKey: true }, + ], + }, + ], +}; diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-case-attachment.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-case-attachment.ts index c9f1bb5dff9a..10ab4168e2d9 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-case-attachment.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-case-attachment.ts @@ -14,6 +14,7 @@ import { querySalesforceApi } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newCaseAttachmentTriggerOutputSchema } from '../output-schemas'; export const newCaseAttachment = createTrigger({ auth: salesforceAuth, @@ -23,6 +24,7 @@ export const newCaseAttachment = createTrigger({ aiMetadata: { description: 'Fires when an attachment is added to any Case in Salesforce, covering both classic Attachments and modern Files (ContentDocumentLink) linked to recently modified Cases. Each event represents one attachment and includes an attachment_type field of either "Classic" or "File". Detected by polling for records modified since the last poll.', }, + outputSchema: newCaseAttachmentTriggerOutputSchema, props: {}, sampleData: { "Id": "00P7Q000002XyA4UAK", diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-case-in-queue.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-case-in-queue.ts index 1e8ad475b695..a6a4e43997b9 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-case-in-queue.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-case-in-queue.ts @@ -8,6 +8,7 @@ import { querySalesforceApi, salesforcesCommon } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newCaseTriggerOutputSchema } from '../output-schemas'; // https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_fields.htm // Hard Salesforce limit: FIELDS(ALL) returns at most 200 records per query @@ -21,6 +22,7 @@ export const newCaseCreatedTrigger = createTrigger({ aiMetadata: { description: 'Fires when a Case becomes owned by the selected Salesforce queue, whether assigned at creation by an Assignment Rule or moved into the queue later via an owner change. Each event emits all fields of the Case. Detected by polling CaseHistory owner changes and newly created Cases owned by the queue since the last poll.', }, + outputSchema: newCaseTriggerOutputSchema, props: { caseQueueId: salesforcesCommon.caseQueueId, }, diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-contact.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-contact.ts index 52765971391f..7e93cfc059f0 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-contact.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-contact.ts @@ -14,6 +14,7 @@ import { querySalesforceApi } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newContactTriggerOutputSchema } from '../output-schemas'; export const newContact = createTrigger({ auth: salesforceAuth, @@ -23,6 +24,7 @@ export const newContact = createTrigger({ aiMetadata: { description: 'Fires once for each new Contact record created in Salesforce, emitting all fields of the created Contact. Detected by polling for records whose CreatedDate is later than the last poll. Does not fire on updates to existing Contacts.', }, + outputSchema: newContactTriggerOutputSchema, props: {}, sampleData: { "Id": "0037Q000005x4aXUAQ", diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-field-history-event.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-field-history-event.ts index 0d7794f863f6..aed194a50e63 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-field-history-event.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-field-history-event.ts @@ -13,6 +13,7 @@ import { querySalesforceApi, salesforcesCommon } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newFieldHistoryEventTriggerOutputSchema } from '../output-schemas'; export const newFieldHistoryEvent = createTrigger({ auth: salesforceAuth, @@ -22,6 +23,7 @@ export const newFieldHistoryEvent = createTrigger({ aiMetadata: { description: 'Fires once for each field-history entry recorded when a history-tracked field changes on the selected Salesforce object (for example Account, Contact, or a custom object). Each event represents a single field change and includes the field name, old value, and new value. Requires field history tracking to be enabled for that object and field in Salesforce; standard objects use the History table and custom objects use the __History table.', }, + outputSchema: newFieldHistoryEventTriggerOutputSchema, props: { object: salesforcesCommon.object, }, diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-lead.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-lead.ts index 9abeeff182f6..8b4b945f5599 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-lead.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-lead.ts @@ -13,6 +13,7 @@ import { querySalesforceApi } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newLeadTriggerOutputSchema } from '../output-schemas'; export const newLead = createTrigger({ auth: salesforceAuth, @@ -22,6 +23,7 @@ export const newLead = createTrigger({ aiMetadata: { description: 'Fires once for each new Lead record created in Salesforce, emitting all fields of the created Lead. Detected by polling for records whose CreatedDate is later than the last poll. Does not fire on updates to existing Leads or when a Lead is converted.', }, + outputSchema: newLeadTriggerOutputSchema, props: {}, sampleData: { "Id": "00Q7Q000003x4aXUAQ", diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-outbound-message.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-outbound-message.ts index 11adac9576bb..60b65dfcca94 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-outbound-message.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-outbound-message.ts @@ -4,6 +4,7 @@ import { } from '@activepieces/pieces-framework'; import { XMLParser } from 'fast-xml-parser'; import { salesforceAuth } from '../..'; +import { newOutboundMessageTriggerOutputSchema } from '../output-schemas'; export const newOutboundMessage = createTrigger({ auth: salesforceAuth, @@ -13,6 +14,7 @@ export const newOutboundMessage = createTrigger({ aiMetadata: { description: 'Fires when Salesforce pushes an Outbound Message notification to this webhook, typically configured via a Workflow Rule or Flow action in Salesforce. The incoming SOAP XML payload is parsed and the event represents one or more notification objects, each carrying the affected sObject and its fields. Requires the Salesforce Outbound Message endpoint to point at this trigger\'s webhook URL.', }, + outputSchema: newOutboundMessageTriggerOutputSchema, props: {}, // See https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_om_outboundmessaging_notification.htm sampleData: { diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-record.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-record.ts index 3df49061b161..d91e661af886 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-record.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-record.ts @@ -14,6 +14,7 @@ import { querySalesforceApi, salesforcesCommon } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newRecordTriggerOutputSchema } from '../output-schemas'; export const newRecord = createTrigger({ auth: salesforceAuth, @@ -23,6 +24,7 @@ export const newRecord = createTrigger({ aiMetadata: { description: 'Fires once for each new record created for the selected Salesforce object, detected by polling for records whose CreatedDate is later than the last poll. Each event emits all fields of the created record. An optional SOQL where-clause condition can further filter which records trigger the event. Does not fire on updates to existing records.', }, + outputSchema: newRecordTriggerOutputSchema, props: { object: salesforcesCommon.object, conditions: Property.LongText({ diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-updated-file.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-updated-file.ts index b5965e835c49..14406b777cb7 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-updated-file.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-updated-file.ts @@ -13,6 +13,7 @@ import { querySalesforceApi } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newUpdatedFileTriggerOutputSchema } from '../output-schemas'; export const newUpdatedFile = createTrigger({ auth: salesforceAuth, @@ -22,6 +23,7 @@ export const newUpdatedFile = createTrigger({ aiMetadata: { description: 'Fires whenever a Salesforce Files ContentDocument is created or modified, detected by polling for records whose SystemModstamp is later than the last poll. Each event represents the current state of one file and includes its fields plus a Type of ContentDocument. Does not fire for classic Attachments or Notes.', }, + outputSchema: newUpdatedFileTriggerOutputSchema, props: {}, sampleData: { "Id": "0697Q000002qB9iQAE", diff --git a/packages/pieces/community/salesforce/src/lib/trigger/new-updated-record.ts b/packages/pieces/community/salesforce/src/lib/trigger/new-updated-record.ts index 04a74b17f90b..17d13d3460cc 100644 --- a/packages/pieces/community/salesforce/src/lib/trigger/new-updated-record.ts +++ b/packages/pieces/community/salesforce/src/lib/trigger/new-updated-record.ts @@ -14,6 +14,7 @@ import { querySalesforceApi, salesforcesCommon } from '../common'; import dayjs from 'dayjs'; import { salesforceAuth } from '../..'; +import { newRecordTriggerOutputSchema } from '../output-schemas'; export const newOrUpdatedRecord = createTrigger({ auth: salesforceAuth, @@ -23,6 +24,7 @@ export const newOrUpdatedRecord = createTrigger({ aiMetadata: { description: 'Fires whenever a record of the selected Salesforce object is created or updated, detected by polling for records whose LastModifiedDate is later than the last poll. Each event emits all fields of the changed record. An optional SOQL where-clause condition can further filter which records trigger the event.', }, + outputSchema: newRecordTriggerOutputSchema, props: { object: salesforcesCommon.object, conditions: Property.LongText({ diff --git a/packages/pieces/community/shopify/package.json b/packages/pieces/community/shopify/package.json index 1307aae42ddc..7959b8618a3c 100644 --- a/packages/pieces/community/shopify/package.json +++ b/packages/pieces/community/shopify/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-shopify", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/shopify/src/i18n/translation.json b/packages/pieces/community/shopify/src/i18n/translation.json index 1dcdff11c7fa..2156732622c9 100644 --- a/packages/pieces/community/shopify/src/i18n/translation.json +++ b/packages/pieces/community/shopify/src/i18n/translation.json @@ -2,7 +2,6 @@ "Ecommerce platform for online stores": "Ecommerce platform for online stores", "Shop Name": "Shop Name", "Admin Token": "Admin Token", - "\n**Shop Name**:\n\nYou can find your shop name in the url For example, if the URL is `https://example.myshopify.com/admin`, then your shop name is **example**.\n\n**Admin Token**:\n\n1. Login to your Shopify account\n2. Go to Settings -> Apps\n3. Click on Develop apps\n4. Create an App\n5. Fill the app name\n6. Click on Configure Admin API Scopes (Select the following scopes 'read_orders', 'write_orders', 'write_customers', 'read_customers', 'write_products', 'read_products', 'write_draft_orders', 'read_draft_orders')": "\n**Shop Name**:\n\nYou can find your shop name in the url For example, if the URL is `https://example.myshopify.com/admin`, then your shop name is **example**.\n\n**Admin Token**:\n\n1. Login to your Shopify account\n2. Go to Settings -> Apps\n3. Click on Develop apps\n4. Create an App\n5. Fill the app name\n6. Click on Configure Admin API Scopes (Select the following scopes 'read_orders', 'write_orders', 'write_customers', 'read_customers', 'write_products', 'read_products', 'write_draft_orders', 'read_draft_orders')\n7. Click on Install app\n8. Copy the Admin Access Token\n", "Adjust Inventory Level": "Adjust Inventory Level", "Cancel Order": "Cancel Order", "Close Order": "Close Order", @@ -157,5 +156,10 @@ "Triggered when a new customer is created": "Triggered when a new customer is created", "Triggered when a new order is created": "Triggered when a new order is created", "Triggered when a product is updated.": "Triggered when a product is updated.", - "Triggered when a paid order is created": "Triggered when a paid order is created" + "Triggered when a paid order is created": "Triggered when a paid order is created", + "\nUse this option for custom apps created in the Shopify admin **before January 1, 2026**. Shopify no longer lets you create these apps, but existing ones keep working and their Admin API access token does not expire. For a new app, choose the **Dev Dashboard app** option instead.\n\n**Shop Name**:\n\nYou can find your shop name in the URL. For example, if the URL is `https://example.myshopify.com/admin`, then your shop name is **example**.\n\n**Admin Token**:\n\n1. Log in to your Shopify admin.\n2. Go to **Settings*": "\nUse this option for custom apps created in the Shopify admin **before January 1, 2026**. Shopify no longer lets you create these apps, but existing ones keep working and their Admin API access token does not expire. For a new app, choose the **Dev Dashboard app** option instead.\n\n**Shop Name**:\n\nYou can find your shop name in the URL. For example, if the URL is `https://example.myshopify.com/admin`, then your shop name is **example**.\n\n**Admin Token**:\n\n1. Log in to your Shopify admin.\n2. Go to **Settings** -> **Apps and sales channels** -> **Develop apps**.\n3. Open your existing custom app.\n4. Under **Configuration**, check that the Admin API scopes include: `read_orders`, `write_orders`, `read_customers`, `write_customers`, `read_products`, `write_products`, `read_draft_orders`, `write_draft_orders`, `read_locations`, `read_inventory`, `write_inventory`, `read_themes`, `write_fulfillments`.\n5. Under **API credentials**, copy the **Admin API access token**.\n", + "Admin Token (legacy custom app)": "Admin Token (legacy custom app)", + "The part before .myshopify.com in your store URL. For https://example.myshopify.com, enter example.": "The part before .myshopify.com in your store URL. For https://example.myshopify.com, enter example.", + "\nUse this option for apps created in the Shopify **Dev Dashboard** (all new apps from January 1, 2026). Activepieces exchanges the app's client ID and client secret for an access token and renews it automatically every 24 hours.\n\n**Important:** the app and the store must belong to the **same Shopify organization**, otherwise Shopify rejects the connection.\n\n1. Go to the [Shopify Dev Dashboard](https://dev.shopify.com/dashboard) and click **Create app**. Give it a name.\n2. Create a version of the app and, un": "\nUse this option for apps created in the Shopify **Dev Dashboard** (all new apps from January 1, 2026). Activepieces exchanges the app's client ID and client secret for an access token and renews it automatically every 24 hours.\n\n**Important:** the app and the store must belong to the **same Shopify organization**, otherwise Shopify rejects the connection.\n\n1. Go to the [Shopify Dev Dashboard](https://dev.shopify.com/dashboard) and click **Create app**. Give it a name.\n2. Create a version of the app and, under **Access** -> **Scopes**, select these Admin API scopes: `read_orders`, `write_orders`, `read_customers`, `write_customers`, `read_products`, `write_products`, `read_draft_orders`, `write_draft_orders`, `read_locations`, `read_inventory`, `write_inventory`, `read_themes`, `write_fulfillments`.\n3. Release a version with these scopes and install/approve it on the store: from the app's **Home** page, click **Install app** and install it on your store (reinstall it if it was installed before the scopes were added). The store must be in the same organization as the app.\n4. Open the app's **Settings** page and copy the **Client ID** and **Client secret**.\n5. Enter your shop name below (for `https://example.myshopify.com`, enter **example**), then paste the client ID and client secret.\n\n**Note:** If you change the app's scopes later, reconnect this connection so a new token is issued (tokens keep the scopes they were issued with for up to 24 hours).\n", + "Dev Dashboard app": "Dev Dashboard app" } \ No newline at end of file diff --git a/packages/pieces/community/shopify/src/index.ts b/packages/pieces/community/shopify/src/index.ts index 4616ae030be2..1353e3b91f64 100644 --- a/packages/pieces/community/shopify/src/index.ts +++ b/packages/pieces/community/shopify/src/index.ts @@ -1,13 +1,5 @@ -import { - HttpMethod, - createCustomApiCallAction, -} from '@activepieces/pieces-common'; -import { - PieceAuth, - Property, - createPiece, -} from '@activepieces/pieces-framework'; -import { AppConnectionType, PieceCategory } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; import { adjustInventoryLevelAction } from './lib/actions/adjust-inventory-level'; import { cancelOrderAction } from './lib/actions/cancel-order'; import { closeOrderAction } from './lib/actions/close-order'; @@ -34,7 +26,7 @@ import { updateCustomerAction } from './lib/actions/update-customer'; import { updateOrderAction } from './lib/actions/update-order'; import { updateProductAction } from './lib/actions/update-product'; import { uploadProductImageAction } from './lib/actions/upload-product-image'; -import { getBaseUrl, sendShopifyRequest } from './lib/common'; +import { shopifyAuth, shopifyAuthHelpers } from './lib/common/auth'; import { newAbandonedCheckout } from './lib/triggers/new-abandoned-checkout'; import { newCancelledOrder } from './lib/triggers/new-cancelled-order'; import { newCustomer } from './lib/triggers/new-customer'; @@ -42,57 +34,7 @@ import { newOrder } from './lib/triggers/new-order'; import { newPaidOrder } from './lib/triggers/new-paid-order'; import { updatedProduct } from './lib/triggers/updated-product'; -const markdown = ` -**Shop Name**: - -You can find your shop name in the url For example, if the URL is \`https://example.myshopify.com/admin\`, then your shop name is **example**. - -**Admin Token**: - -1. Login to your Shopify account -2. Go to Settings -> Apps -3. Click on Develop apps -4. Create an App -5. Fill the app name -6. Click on Configure Admin API Scopes (Select the following scopes 'read_orders', 'write_orders', 'write_customers', 'read_customers', 'write_products', 'read_products', 'write_draft_orders', 'read_draft_orders') -7. Click on Install app -8. Copy the Admin Access Token -`; - -export const shopifyAuth = PieceAuth.CustomAuth({ - description: markdown, - required: true, - props: { - shopName: Property.ShortText({ - displayName: 'Shop Name', - required: true, - }), - adminToken: PieceAuth.SecretText({ - displayName: 'Admin Token', - required: true, - }), - }, - validate: async ({ auth }) => { - try { - await sendShopifyRequest({ - auth: { - type: AppConnectionType.CUSTOM_AUTH, - props: auth, - }, - method: HttpMethod.GET, - url: '/shop.json', - }); - return { - valid: true, - }; - } catch (e) { - return { - valid: false, - error: 'Invalid Shop Name or Admin Token', - }; - } - }, -}); +export { shopifyAuth }; export const shopify = createPiece({ displayName: 'Shopify', @@ -100,7 +42,7 @@ export const shopify = createPiece({ logoUrl: 'https://cdn.activepieces.com/pieces/shopify.png', authors: ["kishanprmr","MoShizzle","AbdulTheActivePiecer","khaledmashaly","abuaboud","ikus060"], categories: [PieceCategory.COMMERCE], - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', auth: shopifyAuth, actions: [ adjustInventoryLevelAction, @@ -131,15 +73,10 @@ export const shopify = createPiece({ uploadProductImageAction, createCustomApiCallAction({ baseUrl: (auth) => { - return auth ? getBaseUrl(auth.props.shopName) : ''; + return auth ? shopifyAuthHelpers.getBaseUrl(auth) : ''; }, auth: shopifyAuth, - authMapping: async (auth) => { - const typedAuth = auth.props.adminToken; - return { - 'X-Shopify-Access-Token': typedAuth, - }; - }, + authMapping: async (auth) => shopifyAuthHelpers.getAuthHeaders(auth), }), ], triggers: [ diff --git a/packages/pieces/community/shopify/src/lib/common/auth.ts b/packages/pieces/community/shopify/src/lib/common/auth.ts new file mode 100644 index 000000000000..bcfbbd2d17db --- /dev/null +++ b/packages/pieces/community/shopify/src/lib/common/auth.ts @@ -0,0 +1,197 @@ +import { + HttpError, + HttpMethod, + httpClient, +} from '@activepieces/pieces-common'; +import { + AppConnectionType, + AppConnectionValueForAuthProperty, + OAuth2GrantType, + PieceAuth, + Property, +} from '@activepieces/pieces-framework'; + +const adminTokenMarkdown = ` +Use this option for custom apps created in the Shopify admin **before January 1, 2026**. Shopify no longer lets you create these apps, but existing ones keep working and their Admin API access token does not expire. For a new app, choose the **Dev Dashboard app** option instead. + +**Shop Name**: + +You can find your shop name in the URL. For example, if the URL is \`https://example.myshopify.com/admin\`, then your shop name is **example**. + +**Admin Token**: + +1. Log in to your Shopify admin. +2. Go to **Settings** -> **Apps and sales channels** -> **Develop apps**. +3. Open your existing custom app. +4. Under **Configuration**, check that the Admin API scopes include: \`read_orders\`, \`write_orders\`, \`read_customers\`, \`write_customers\`, \`read_products\`, \`write_products\`, \`read_draft_orders\`, \`write_draft_orders\`, \`read_locations\`, \`read_inventory\`, \`write_inventory\`, \`read_themes\`, \`write_fulfillments\`. +5. Under **API credentials**, copy the **Admin API access token**. +`; + +const devDashboardMarkdown = ` +Use this option for apps created in the Shopify **Dev Dashboard** (all new apps from January 1, 2026). Activepieces exchanges the app's client ID and client secret for an access token and renews it automatically every 24 hours. + +**Important:** the app and the store must belong to the **same Shopify organization**, otherwise Shopify rejects the connection. + +1. Go to the [Shopify Dev Dashboard](https://dev.shopify.com/dashboard) and click **Create app**. Give it a name. +2. Create a version of the app and, under **Access** -> **Scopes**, select these Admin API scopes: \`read_orders\`, \`write_orders\`, \`read_customers\`, \`write_customers\`, \`read_products\`, \`write_products\`, \`read_draft_orders\`, \`write_draft_orders\`, \`read_locations\`, \`read_inventory\`, \`write_inventory\`, \`read_themes\`, \`write_fulfillments\`. +3. Release a version with these scopes and install/approve it on the store: from the app's **Home** page, click **Install app** and install it on your store (reinstall it if it was installed before the scopes were added). The store must be in the same organization as the app. +4. Open the app's **Settings** page and copy the **Client ID** and **Client secret**. +5. Enter your shop name below (for \`https://example.myshopify.com\`, enter **example**), then paste the client ID and client secret. + +**Note:** If you change the app's scopes later, reconnect this connection so a new token is issued (tokens keep the scopes they were issued with for up to 24 hours). +`; + +const NO_APPROVED_SCOPES_ERROR = + 'Your Shopify app has no approved Admin API scopes on this store. In the Dev Dashboard, release an app version that includes the scopes, then install/approve it on the store (reinstall if needed), and try again.'; + +const SHOP_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; + +const INVALID_SHOP_NAME_ERROR = + 'Invalid Shop Name. Enter only the part before .myshopify.com in your store URL (letters, numbers and hyphens), for example "example" for https://example.myshopify.com.'; + +export function getBaseUrl(shopName: string) { + if (!isValidShopName(shopName)) { + throw new Error(INVALID_SHOP_NAME_ERROR); + } + return `https://${shopName}.myshopify.com/admin/api/2023-10`; +} + +export const shopifyAdminTokenAuth = PieceAuth.CustomAuth({ + displayName: 'Admin Token (legacy custom app)', + description: adminTokenMarkdown, + required: true, + props: { + shopName: Property.ShortText({ + displayName: 'Shop Name', + required: true, + }), + adminToken: PieceAuth.SecretText({ + displayName: 'Admin Token', + required: true, + }), + }, + validate: async ({ auth }) => { + if (!isValidShopName(auth.shopName)) { + return { valid: false, error: INVALID_SHOP_NAME_ERROR }; + } + try { + await httpClient.sendRequest({ + url: `${getBaseUrl(auth.shopName)}/shop.json`, + method: HttpMethod.GET, + headers: { + 'X-Shopify-Access-Token': auth.adminToken, + }, + }); + return { + valid: true, + }; + } catch (e) { + return { + valid: false, + error: 'Invalid Shop Name or Admin Token', + }; + } + }, +}); + +export const shopifyDevDashboardAuth = PieceAuth.OAuth2({ + displayName: 'Dev Dashboard app', + description: devDashboardMarkdown, + required: true, + grantType: OAuth2GrantType.CLIENT_CREDENTIALS, + authUrl: '', + tokenUrl: 'https://{shopName}.myshopify.com/admin/oauth/access_token', + scope: [], + props: { + shopName: Property.ShortText({ + displayName: 'Shop Name', + description: + 'The part before .myshopify.com in your store URL. For https://example.myshopify.com, enter example.', + required: true, + }), + }, + validate: async ({ auth }) => { + const shopName = auth.props?.['shopName']; + if (!isValidShopName(shopName)) { + return { valid: false, error: INVALID_SHOP_NAME_ERROR }; + } + try { + const scopesResponse = await httpClient.sendRequest<{ + access_scopes?: { handle: string }[]; + }>({ + url: `https://${shopName}.myshopify.com/admin/oauth/access_scopes.json`, + method: HttpMethod.GET, + headers: { + 'X-Shopify-Access-Token': auth.access_token, + }, + }); + const accessScopes = scopesResponse.body?.access_scopes; + if (!Array.isArray(accessScopes) || accessScopes.length === 0) { + return { valid: false, error: NO_APPROVED_SCOPES_ERROR }; + } + await httpClient.sendRequest({ + url: `${getBaseUrl(shopName)}/shop.json`, + method: HttpMethod.GET, + headers: { + 'X-Shopify-Access-Token': auth.access_token, + }, + }); + return { valid: true }; + } catch (e) { + return { + valid: false, + error: devDashboardValidationError( + e instanceof HttpError ? e.response.status : undefined, + ), + }; + } + }, +}); + +export const shopifyAuth = [shopifyAdminTokenAuth, shopifyDevDashboardAuth]; + +export const shopifyAuthHelpers = { + getShopName, + getAccessToken, + getBaseUrl: (auth: ShopifyAuth) => getBaseUrl(getShopName(auth)), + getAuthHeaders: (auth: ShopifyAuth) => ({ + 'X-Shopify-Access-Token': getAccessToken(auth), + }), +}; + +function devDashboardValidationError(status: number | undefined): string { + switch (status) { + case 401: + return 'Shopify rejected the access token. Check that the app is installed on this store and that the store belongs to the same organization as the app.'; + case 403: + return 'The app is missing required Admin API scopes. Add the scopes listed above to the app version, release it, and reinstall the app on the store.'; + case 404: + return 'Store not found. Check the Shop Name (for https://example.myshopify.com, enter example).'; + default: + return 'Could not connect to Shopify with this Dev Dashboard app. Check the Shop Name, client ID and client secret.'; + } +} + +function isValidShopName(value: unknown): value is string { + return typeof value === 'string' && SHOP_NAME_PATTERN.test(value); +} + +function getShopName(auth: ShopifyAuth): string { + const shopName = + auth.type === AppConnectionType.CUSTOM_AUTH + ? auth.props.shopName + : auth.props?.['shopName']; + if (!isValidShopName(shopName)) { + throw new Error(INVALID_SHOP_NAME_ERROR); + } + return shopName; +} + +function getAccessToken(auth: ShopifyAuth): string { + if (auth.type === AppConnectionType.CUSTOM_AUTH) { + return auth.props.adminToken; + } + return auth.access_token; +} + +export type ShopifyAuth = AppConnectionValueForAuthProperty; diff --git a/packages/pieces/community/shopify/src/lib/common/index.ts b/packages/pieces/community/shopify/src/lib/common/index.ts index a1f908131f61..47fd4a46ae46 100644 --- a/packages/pieces/community/shopify/src/lib/common/index.ts +++ b/packages/pieces/community/shopify/src/lib/common/index.ts @@ -1,12 +1,13 @@ import { + HttpError, HttpMessageBody, HttpMethod, HttpResponse, QueryParams, httpClient, } from '@activepieces/pieces-common'; +import { ShopifyAuth, getBaseUrl, shopifyAuthHelpers } from './auth'; import { - ShopifyAuth, ShopifyCheckout, ShopifyCollect, ShopifyCustomer, @@ -20,9 +21,7 @@ import { ShopifyTransaction, } from './types'; -export function getBaseUrl(shopName: string) { - return `https://${shopName}.myshopify.com/admin/api/2023-10`; -} +export { getBaseUrl, shopifyAuthHelpers }; export function sendShopifyRequest(data: { url: string; @@ -31,15 +30,17 @@ export function sendShopifyRequest(data: { queryParams?: QueryParams; auth: ShopifyAuth; }): Promise> { - return httpClient.sendRequest({ - url: `${getBaseUrl(data.auth.props.shopName)}${data.url}`, - method: data.method, - body: data.body, - queryParams: data.queryParams, - headers: { - 'X-Shopify-Access-Token': data.auth.props.adminToken, - }, - }); + return httpClient + .sendRequest({ + url: `${shopifyAuthHelpers.getBaseUrl(data.auth)}${data.url}`, + method: data.method, + body: data.body, + queryParams: data.queryParams, + headers: shopifyAuthHelpers.getAuthHeaders(data.auth), + }) + .catch((error: unknown) => { + throw withMerchantApprovalHint(error); + }); } export async function createCustomer( @@ -524,3 +525,28 @@ export async function getAbandonedCheckouts( return (response.body as { checkouts: ShopifyCheckout[] }).checkouts; } + +function withMerchantApprovalHint(error: unknown): unknown { + if (!(error instanceof HttpError) || error.response.status !== 403) { + return error; + } + const body = error.response.body; + if (typeof body !== 'object' || body === null || !('errors' in body)) { + return error; + } + const errors = body.errors; + if (typeof errors !== 'string') { + return error; + } + const scope = errors.match(/requires merchant approval for (\S+) scope/)?.[1]; + if (!scope) { + return error; + } + return new HttpError(error.request.body, { + status: 403, + responseBody: { + ...body, + errors: `${errors} — approve the ${scope} scope for your app on the store, then reconnect.`, + }, + }); +} diff --git a/packages/pieces/community/shopify/src/lib/common/register-webhook.ts b/packages/pieces/community/shopify/src/lib/common/register-webhook.ts index 57c9dd325c52..a5de19a864eb 100644 --- a/packages/pieces/community/shopify/src/lib/common/register-webhook.ts +++ b/packages/pieces/community/shopify/src/lib/common/register-webhook.ts @@ -4,7 +4,7 @@ import { Trigger, TriggerStrategy, } from '@activepieces/pieces-framework'; -import { shopifyAuth } from '../..'; +import { shopifyAuth, shopifyAuthHelpers } from './auth'; export const createShopifyWebhookTrigger = ({ name, @@ -31,7 +31,7 @@ export const createShopifyWebhookTrigger = ({ sampleData: sampleData, type: TriggerStrategy.WEBHOOK, async onEnable(context) { - const shopName = context.auth.props.shopName; + const shopName = shopifyAuthHelpers.getShopName(context.auth); const response = await httpClient.sendRequest<{ webhook: { id: string; @@ -39,9 +39,7 @@ export const createShopifyWebhookTrigger = ({ }>({ method: HttpMethod.POST, url: `https://${shopName}.myshopify.com/admin/api/2023-01/webhooks.json`, - headers: { - 'X-Shopify-Access-Token': context.auth.props.adminToken, - }, + headers: shopifyAuthHelpers.getAuthHeaders(context.auth), body: { webhook: { topic: topic, @@ -55,7 +53,7 @@ export const createShopifyWebhookTrigger = ({ }, async onDisable(context) { const webhookId = await context.store.get(`shopify_webhook_id`); - const shopName = context.auth.props.shopName; + const shopName = shopifyAuthHelpers.getShopName(context.auth); await httpClient.sendRequest<{ webhook: { id: string; @@ -63,9 +61,7 @@ export const createShopifyWebhookTrigger = ({ }>({ method: HttpMethod.DELETE, url: `https://${shopName}.myshopify.com/admin/api/2023-01/webhooks/${webhookId}.json`, - headers: { - 'X-Shopify-Access-Token': context.auth.props.adminToken, - }, + headers: shopifyAuthHelpers.getAuthHeaders(context.auth), }); await context.store?.put(`shopify_webhook_id`, null); }, diff --git a/packages/pieces/community/shopify/src/lib/common/types.ts b/packages/pieces/community/shopify/src/lib/common/types.ts index 00791e7c8f32..09bc07a2e959 100644 --- a/packages/pieces/community/shopify/src/lib/common/types.ts +++ b/packages/pieces/community/shopify/src/lib/common/types.ts @@ -1,7 +1,4 @@ -import { AppConnectionValueForAuthProperty } from "@activepieces/pieces-framework"; -import { shopifyAuth } from "../.."; - -export type ShopifyAuth = AppConnectionValueForAuthProperty; +export type { ShopifyAuth } from './auth'; export type ShopifyCustomer = { id: number; diff --git a/packages/pieces/community/typeform/package.json b/packages/pieces/community/typeform/package.json index f76daeb7a618..1a85ab47068f 100644 --- a/packages/pieces/community/typeform/package.json +++ b/packages/pieces/community/typeform/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-typeform", - "version": "0.5.0", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/typeform/src/index.ts b/packages/pieces/community/typeform/src/index.ts index bfc26daa19f4..a31bb824ecbe 100644 --- a/packages/pieces/community/typeform/src/index.ts +++ b/packages/pieces/community/typeform/src/index.ts @@ -1,27 +1,72 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { - OAuth2PropertyValue, - PieceAuth, - createPiece, -} from '@activepieces/pieces-framework'; -import { PieceCategory } from '@activepieces/pieces-framework'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { typeformAuth } from './lib/auth'; import { typeformNewSubmission } from './lib/trigger/new-submission'; +import { createFormAction } from './lib/actions/create-form'; +import { createThemeAction } from './lib/actions/create-theme'; +import { createWorkspaceAction } from './lib/actions/create-workspace'; +import { deleteFormAction } from './lib/actions/delete-form'; +import { deleteImageAction } from './lib/actions/delete-image'; +import { deleteResponsesAction } from './lib/actions/delete-responses'; +import { deleteThemeAction } from './lib/actions/delete-theme'; +import { deleteWorkspaceAction } from './lib/actions/delete-workspace'; +import { duplicateFormAction } from './lib/actions/duplicate-form'; +import { getFormMessagesAction } from './lib/actions/get-form-messages'; +import { getFormAction } from './lib/actions/get-form'; +import { getImageAction } from './lib/actions/get-image'; +import { getMeAction } from './lib/actions/get-me'; +import { getThemeAction } from './lib/actions/get-theme'; +import { getWorkspaceAction } from './lib/actions/get-workspace'; +import { listFormsAction } from './lib/actions/list-forms'; +import { listImagesAction } from './lib/actions/list-images'; +import { listResponsesAction } from './lib/actions/list-responses'; +import { listThemesAction } from './lib/actions/list-themes'; +import { listWorkspacesAction } from './lib/actions/list-workspaces'; +import { replaceFormAction } from './lib/actions/replace-form'; +import { updateChoiceOptionsAction } from './lib/actions/update-choice-options'; +import { updateFormMessagesAction } from './lib/actions/update-form-messages'; +import { updateFormAction } from './lib/actions/update-form'; +import { updateThemeAction } from './lib/actions/update-theme'; +import { updateWorkspaceAction } from './lib/actions/update-workspace'; +import { uploadImageAction } from './lib/actions/upload-image'; -export const typeformAuth = PieceAuth.OAuth2({ - required: true, - tokenUrl: 'https://api.typeform.com/oauth/token', - authUrl: 'https://admin.typeform.com/oauth/authorize', - scope: ['webhooks:write', 'forms:read'], -}); +export { typeformAuth }; export const typeform = createPiece({ displayName: 'Typeform', description: 'Create beautiful online forms and surveys', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/typeform.png', categories: [PieceCategory.FORMS_AND_SURVEYS], actions: [ + getMeAction, + listFormsAction, + getFormAction, + createFormAction, + updateFormAction, + replaceFormAction, + duplicateFormAction, + updateChoiceOptionsAction, + deleteFormAction, + getFormMessagesAction, + updateFormMessagesAction, + listResponsesAction, + deleteResponsesAction, + listWorkspacesAction, + getWorkspaceAction, + createWorkspaceAction, + updateWorkspaceAction, + deleteWorkspaceAction, + listThemesAction, + getThemeAction, + createThemeAction, + updateThemeAction, + deleteThemeAction, + listImagesAction, + getImageAction, + uploadImageAction, + deleteImageAction, createCustomApiCallAction({ baseUrl: () => 'https://api.typeform.com', auth: typeformAuth, diff --git a/packages/pieces/community/typeform/src/lib/actions/create-form.ts b/packages/pieces/community/typeform/src/lib/actions/create-form.ts new file mode 100644 index 000000000000..1aa4d315fa84 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/create-form.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const createFormAction = createAction({ + auth: typeformAuth, + name: 'create_form', + classification: 'WRITE', + displayName: 'Create Form', + description: 'Creates a new form, empty or with questions.', + audience: 'both', + aiMetadata: { + description: + 'Create a new Typeform form with a title, optionally in a workspace, with a theme and with questions. Questions are a JSON array of Typeform field objects, for example [{"title":"Your email?","type":"email","validations":{"required":true}}]; leave empty for an empty form. Returns the new form with its ID and share link. Each call creates another form.', + idempotent: false, + }, + outputSchema: formOutputSchema, + props: { + title: Property.ShortText({ + displayName: 'Title', + required: true, + }), + workspace: typeformCommon.workspace, + theme: typeformCommon.theme, + fields: Property.Json({ + displayName: 'Questions', + description: + 'JSON array of Typeform field objects, as documented in the Typeform Create API. Leave empty for an empty form.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const { title, workspace, theme, fields } = propsValue; + if (fields !== undefined && fields !== null && !Array.isArray(fields)) { + throw new Error('Questions must be a JSON array of field objects.'); + } + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.POST, + path: '/forms', + body: { + title, + ...(typeformCommon.isProvided(workspace) ? { workspace: typeformCommon.toHref({ resource: 'workspaces', id: workspace }) } : {}), + ...(typeformCommon.isProvided(theme) ? { theme: typeformCommon.toHref({ resource: 'themes', id: theme }) } : {}), + ...(Array.isArray(fields) && fields.length > 0 ? { fields } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/create-theme.ts b/packages/pieces/community/typeform/src/lib/actions/create-theme.ts new file mode 100644 index 000000000000..2b6d52d4410e --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/create-theme.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { themeOutputSchema } from '../output-schemas'; + +export const createThemeAction = createAction({ + auth: typeformAuth, + name: 'create_theme', + classification: 'WRITE', + displayName: 'Create Theme', + description: 'Creates a custom theme.', + audience: 'ai', + aiMetadata: { + description: + 'Create a private Typeform theme with a name, font, the four colors (question, answer, button, background, as hex like #3D3D3D) and alignment and font size for questions and screens. Alignment defaults to left and font size to medium. Apply it to a form with Update Form. Each call creates another theme.', + idempotent: false, + }, + outputSchema: themeOutputSchema, + props: { + ...typeformCommon.themeFields, + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + questionColor: Property.ShortText({ + displayName: 'Question Color', + description: 'Hex color, for example #3D3D3D.', + required: true, + }), + answerColor: Property.ShortText({ + displayName: 'Answer Color', + description: 'Hex color.', + required: true, + }), + buttonColor: Property.ShortText({ + displayName: 'Button Color', + description: 'Hex color.', + required: true, + }), + backgroundColor: Property.ShortText({ + displayName: 'Background Color', + description: 'Hex color.', + required: true, + }), + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.POST, + path: '/themes', + body: typeformCommon.themeBody({ + values: { + ...propsValue, + fieldAlignment: propsValue.fieldAlignment ?? 'left', + fieldFontSize: propsValue.fieldFontSize ?? 'medium', + screenAlignment: propsValue.screenAlignment ?? 'left', + screenFontSize: propsValue.screenFontSize ?? 'medium', + }, + current: {}, + }), + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/create-workspace.ts b/packages/pieces/community/typeform/src/lib/actions/create-workspace.ts new file mode 100644 index 000000000000..cc721f3ae034 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/create-workspace.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { workspaceOutputSchema } from '../output-schemas'; + +export const createWorkspaceAction = createAction({ + auth: typeformAuth, + name: 'create_workspace', + classification: 'WRITE', + displayName: 'Create Workspace', + description: 'Creates a new workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Create a Typeform workspace to group forms. By default it goes in the account the connected user owns; set Account ID (account_id from List Workspaces) to create it in another account the user belongs to. Returns the new workspace with its ID. Each call creates another workspace.', + idempotent: false, + }, + outputSchema: workspaceOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + accountId: Property.ShortText({ + displayName: 'Account ID', + description: 'account_id from List Workspaces. Leave empty for your own account.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const { name, accountId } = propsValue; + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.POST, + path: typeformCommon.isProvided(accountId) + ? `/accounts/${encodeURIComponent(accountId.trim())}/workspaces` + : '/workspaces', + body: { name }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/delete-form.ts b/packages/pieces/community/typeform/src/lib/actions/delete-form.ts new file mode 100644 index 000000000000..85d619fea977 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/delete-form.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon } from '../common'; +import { deleteFormOutputSchema } from '../output-schemas'; + +export const deleteFormAction = createAction({ + auth: typeformAuth, + name: 'delete_form', + classification: 'DESTRUCTIVE', + displayName: 'Delete Form', + description: 'Permanently deletes a form and all of its responses.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete a Typeform form together with all of its responses. Cannot be undone; confirm with the user first. To stop new responses without losing data, use Update Form with Public set to Closed instead.', + idempotent: false, + }, + outputSchema: deleteFormOutputSchema, + props: { + form_id: typeformCommon.formId, + }, + async run({ auth, propsValue }) { + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.DELETE, + path: `/forms/${encodeURIComponent(propsValue.form_id)}`, + }); + return { deleted: true, form_id: propsValue.form_id }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/delete-image.ts b/packages/pieces/community/typeform/src/lib/actions/delete-image.ts new file mode 100644 index 000000000000..9bb2892cdd18 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/delete-image.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon } from '../common'; +import { deleteImageOutputSchema } from '../output-schemas'; + +export const deleteImageAction = createAction({ + auth: typeformAuth, + name: 'delete_image', + classification: 'DESTRUCTIVE', + displayName: 'Delete Image', + description: 'Deletes an image from the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'Delete an image from the Typeform account by ID. Forms and themes that use it may show it missing. Cannot be undone; confirm with the user first.', + idempotent: false, + }, + outputSchema: deleteImageOutputSchema, + props: { + imageId: Property.ShortText({ + displayName: 'Image ID', + description: 'Image ID, from List Images.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const imageId = propsValue.imageId.trim(); + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.DELETE, + path: `/images/${encodeURIComponent(imageId)}`, + }); + return { deleted: true, image_id: imageId }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/delete-responses.ts b/packages/pieces/community/typeform/src/lib/actions/delete-responses.ts new file mode 100644 index 000000000000..16422f97fafb --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/delete-responses.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon } from '../common'; +import { deleteResponsesOutputSchema } from '../output-schemas'; + +export const deleteResponsesAction = createAction({ + auth: typeformAuth, + name: 'delete_responses', + classification: 'DESTRUCTIVE', + displayName: 'Delete Responses', + description: 'Permanently deletes responses from a form.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently delete up to 1000 responses from a Typeform form by response ID (the response_id or token from Find Responses). Typeform deletes them in the background, so the result only means the request was accepted; unknown IDs are ignored. Check with Find Responses afterwards. Cannot be undone; confirm with the user first.', + idempotent: false, + }, + outputSchema: deleteResponsesOutputSchema, + props: { + form_id: typeformCommon.formId, + responseIds: Property.Array({ + displayName: 'Response IDs', + required: true, + }), + }, + async run({ auth, propsValue }) { + const ids = typeformCommon.toStringList({ values: propsValue.responseIds }); + if (ids.length === 0 || ids.length > 1000) { + throw new Error('Response IDs must contain between 1 and 1000 IDs.'); + } + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.DELETE, + path: `/forms/${encodeURIComponent(propsValue.form_id)}/responses`, + queryParams: { included_response_ids: ids.join(',') }, + }); + return { registered: true, form_id: propsValue.form_id, response_ids: ids, count: ids.length }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/delete-theme.ts b/packages/pieces/community/typeform/src/lib/actions/delete-theme.ts new file mode 100644 index 000000000000..de5c04727972 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/delete-theme.ts @@ -0,0 +1,40 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { deleteThemeOutputSchema } from '../output-schemas'; + +export const deleteThemeAction = createAction({ + auth: typeformAuth, + name: 'delete_theme', + classification: 'DESTRUCTIVE', + displayName: 'Delete Theme', + description: 'Deletes a custom theme.', + audience: 'ai', + aiMetadata: { + description: + "Delete a private Typeform theme. Typeform's public preset themes cannot be deleted. Cannot be undone; confirm with the user first.", + idempotent: false, + }, + outputSchema: deleteThemeOutputSchema, + props: { + themeId: typeformCommon.requiredThemeId, + }, + async run({ auth, propsValue }) { + const path = `/themes/${encodeURIComponent(propsValue.themeId.trim())}`; + const current = await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path, + }); + if (current['visibility'] === 'public') { + throw new Error("This is one of Typeform's public themes and cannot be deleted."); + } + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.DELETE, + path, + }); + return { deleted: true, theme_id: propsValue.themeId.trim() }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/delete-workspace.ts b/packages/pieces/community/typeform/src/lib/actions/delete-workspace.ts new file mode 100644 index 000000000000..f771626669bc --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/delete-workspace.ts @@ -0,0 +1,47 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { deleteWorkspaceOutputSchema } from '../output-schemas'; + +export const deleteWorkspaceAction = createAction({ + auth: typeformAuth, + name: 'delete_workspace', + classification: 'DESTRUCTIVE', + displayName: 'Delete Workspace', + description: 'Deletes an empty workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Delete an empty Typeform workspace. Typeform would delete every form in a workspace and all their responses along with it, so this action refuses workspaces that still contain forms; move them with Update Form or delete them first. The default workspace cannot be deleted. Cannot be undone; confirm with the user first.', + idempotent: false, + }, + outputSchema: deleteWorkspaceOutputSchema, + props: { + workspace: typeformCommon.requiredWorkspaceId, + }, + async run({ auth, propsValue }) { + const path = `/workspaces/${encodeURIComponent(propsValue.workspace)}`; + const current = await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path, + }); + if (current['default'] === true) { + throw new Error('The default workspace cannot be deleted.'); + } + const forms = current['forms']; + const formCount = typeformCommon.isRecord(forms) && typeof forms['count'] === 'number' ? forms['count'] : 0; + if (formCount > 0) { + throw new Error( + `This workspace still contains ${formCount} form(s), and deleting it would delete them and all their responses. Move them to another workspace with Update Form, or delete them first.` + ); + } + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.DELETE, + path, + }); + return { deleted: true, workspace_id: propsValue.workspace }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/duplicate-form.ts b/packages/pieces/community/typeform/src/lib/actions/duplicate-form.ts new file mode 100644 index 000000000000..d0fb7e67392c --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/duplicate-form.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const duplicateFormAction = createAction({ + auth: typeformAuth, + name: 'duplicate_form', + classification: 'WRITE', + displayName: 'Duplicate Form', + description: 'Creates a copy of an existing form.', + audience: 'both', + aiMetadata: { + description: + 'Create a copy of a Typeform form with the same questions, screens, logic, settings and theme, optionally with a new title and in another workspace. Responses, translations and webhooks are not copied. Returns the new form. Each call creates another copy.', + idempotent: false, + }, + outputSchema: formOutputSchema, + props: { + form_id: typeformCommon.form, + title: Property.ShortText({ + displayName: 'New Title', + description: 'Defaults to "Copy of" and the original title.', + required: false, + }), + workspace: typeformCommon.workspace, + }, + async run({ auth, propsValue }) { + const { form_id, title, workspace } = propsValue; + const source = await typeformCommon.getForm({ token: auth.access_token, formId: form_id }); + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.POST, + path: '/forms', + body: { + ...typeformCommon.toDuplicatePayload({ form: source }), + title: typeformCommon.isProvided(title) ? title : `Copy of ${String(source['title'] ?? '')}`.trim(), + ...(typeformCommon.isProvided(workspace) + ? { workspace: typeformCommon.toHref({ resource: 'workspaces', id: workspace }) } + : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-form-messages.ts b/packages/pieces/community/typeform/src/lib/actions/get-form-messages.ts new file mode 100644 index 000000000000..d3ffc6192e8b --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-form-messages.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; + +export const getFormMessagesAction = createAction({ + auth: typeformAuth, + name: 'get_form_messages', + classification: 'READ', + displayName: 'Get Form Messages', + description: 'Gets the button labels, error messages and other texts of a form.', + audience: 'ai', + aiMetadata: { + description: + 'Get the custom interface texts of a Typeform form (button labels, error messages, placeholders), keyed like "label.buttonHint.default". Use it to see the keys before Update Form Messages. Read-only.', + idempotent: true, + }, + props: { + form_id: typeformCommon.formId, + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: `/forms/${encodeURIComponent(propsValue.form_id)}/messages`, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-form.ts b/packages/pieces/community/typeform/src/lib/actions/get-form.ts new file mode 100644 index 000000000000..eee71dd5715e --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-form.ts @@ -0,0 +1,25 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { typeformAuth } from '../auth'; +import { typeformCommon } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const getFormAction = createAction({ + auth: typeformAuth, + name: 'get_form', + classification: 'READ', + displayName: 'Get Form', + description: 'Gets a form with its questions, logic and settings.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Typeform form by ID with its questions (fields, with IDs, refs, types and choices), welcome and thank-you screens, logic, hidden fields, settings, theme and workspace. Use it before Replace Form or Update Choice Options, and to read field IDs for response answers. Read-only.', + idempotent: true, + }, + outputSchema: formOutputSchema, + props: { + form_id: typeformCommon.formId, + }, + async run({ auth, propsValue }) { + return typeformCommon.getForm({ token: auth.access_token, formId: propsValue.form_id }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-image.ts b/packages/pieces/community/typeform/src/lib/actions/get-image.ts new file mode 100644 index 000000000000..b5b0dca0f818 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-image.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { imageOutputSchema } from '../output-schemas'; + +export const getImageAction = createAction({ + auth: typeformAuth, + name: 'get_image', + classification: 'READ', + displayName: 'Get Image', + description: 'Gets an image with its link and details.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Typeform image by ID with its file name, media type, width, height and link (src). Read-only.', + idempotent: true, + }, + outputSchema: imageOutputSchema, + props: { + imageId: Property.ShortText({ + displayName: 'Image ID', + description: 'Image ID, from List Images.', + required: true, + }), + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: `/images/${encodeURIComponent(propsValue.imageId.trim())}`, + headers: { Accept: 'application/json' }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-me.ts b/packages/pieces/community/typeform/src/lib/actions/get-me.ts new file mode 100644 index 000000000000..25b2a42058e2 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-me.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { accountOutputSchema } from '../output-schemas'; + +export const getMeAction = createAction({ + auth: typeformAuth, + name: 'get_me', + classification: 'READ', + displayName: 'Get Account', + description: 'Gets the connected Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'Get the connected Typeform account: alias, email and language. Use it to confirm which account is connected. It does not return the account ID; use List Workspaces for that. Read-only.', + idempotent: true, + }, + outputSchema: accountOutputSchema, + props: {}, + async run({ auth }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: '/me', + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-theme.ts b/packages/pieces/community/typeform/src/lib/actions/get-theme.ts new file mode 100644 index 000000000000..4c16c16a5de4 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-theme.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { themeOutputSchema } from '../output-schemas'; + +export const getThemeAction = createAction({ + auth: typeformAuth, + name: 'get_theme', + classification: 'READ', + displayName: 'Get Theme', + description: 'Gets a theme with its colors, font and layout.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Typeform theme by ID with its name, font, colors, question and screen alignment and font size, background and visibility. Read-only.', + idempotent: true, + }, + outputSchema: themeOutputSchema, + props: { + themeId: typeformCommon.requiredThemeId, + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: `/themes/${encodeURIComponent(propsValue.themeId.trim())}`, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/get-workspace.ts b/packages/pieces/community/typeform/src/lib/actions/get-workspace.ts new file mode 100644 index 000000000000..3a41273beec0 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/get-workspace.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { workspaceOutputSchema } from '../output-schemas'; + +export const getWorkspaceAction = createAction({ + auth: typeformAuth, + name: 'get_workspace', + classification: 'READ', + displayName: 'Get Workspace', + description: 'Gets a workspace with its members.', + audience: 'ai', + aiMetadata: { + description: + 'Get one Typeform workspace by ID with its name, account ID, default and shared flags, form count and members with their roles. Use List Forms with this workspace to see its forms. Read-only.', + idempotent: true, + }, + outputSchema: workspaceOutputSchema, + props: { + workspace: typeformCommon.requiredWorkspaceId, + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: `/workspaces/${encodeURIComponent(propsValue.workspace)}`, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/list-forms.ts b/packages/pieces/community/typeform/src/lib/actions/list-forms.ts new file mode 100644 index 000000000000..006a234289ed --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/list-forms.ts @@ -0,0 +1,68 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformPage, TypeformRecord } from '../common'; +import { formsOutputSchema } from '../output-schemas'; + +export const listFormsAction = createAction({ + auth: typeformAuth, + name: 'list_forms', + classification: 'SEARCH', + displayName: 'List Forms', + description: 'Lists forms in the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'List Typeform forms with each ID, title, public status, created and updated time and share link. Filter by title text and workspace, sort by created or updated time. Paginated with Page and Page Size (up to 200); total_items and page_count show how many pages exist. Use Get Form for the questions. Read-only.', + idempotent: true, + }, + outputSchema: formsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only forms whose title contains this text.', + required: false, + }), + workspace: typeformCommon.workspaceId, + sortBy: Property.StaticDropdown({ + displayName: 'Sort By', + required: false, + options: { + options: [ + { label: 'Created at', value: 'created_at' }, + { label: 'Last updated at', value: 'last_updated_at' }, + ], + }, + }), + orderBy: Property.StaticDropdown({ + displayName: 'Order', + required: false, + options: { + options: [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' }, + ], + }, + }), + page: typeformCommon.page, + pageSize: typeformCommon.pageSize, + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest>({ + token: auth.access_token, + method: HttpMethod.GET, + path: '/forms', + queryParams: { + search: propsValue.search?.trim(), + workspace_id: propsValue.workspace, + sort_by: propsValue.sortBy, + order_by: propsValue.orderBy, + ...typeformCommon.pageQuery({ + page: propsValue.page, + pageSize: propsValue.pageSize, + maxPageSize: typeformCommon.maxPageSize, + }), + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/list-images.ts b/packages/pieces/community/typeform/src/lib/actions/list-images.ts new file mode 100644 index 000000000000..197d095700e1 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/list-images.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { imagesOutputSchema } from '../output-schemas'; + +export const listImagesAction = createAction({ + auth: typeformAuth, + name: 'list_images', + classification: 'SEARCH', + displayName: 'List Images', + description: 'Lists images uploaded to the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'List images uploaded to the Typeform account, newest first, with each ID, file name and link. Typeform returns them all at once; Limit caps how many are returned. Read-only.', + idempotent: true, + }, + outputSchema: imagesOutputSchema, + props: { + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of images to return. Default 50.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const images = await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path: '/images', + }); + const limit = Math.max(1, Math.floor(propsValue.limit ?? 50)); + const items = images.slice(0, limit); + return { items, count: items.length, total_items: images.length }; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/list-responses.ts b/packages/pieces/community/typeform/src/lib/actions/list-responses.ts new file mode 100644 index 000000000000..4361993a0764 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/list-responses.ts @@ -0,0 +1,96 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformPage, TypeformRecord } from '../common'; +import { responsesOutputSchema } from '../output-schemas'; + +export const listResponsesAction = createAction({ + auth: typeformAuth, + name: 'list_responses', + classification: 'SEARCH', + displayName: 'Find Responses', + description: 'Looks up responses to a form.', + audience: 'both', + aiMetadata: { + description: + 'List responses to one Typeform form, newest first by default, with answers (each tied to a field ID from Get Form), hidden fields, score, submitted and landed time. Filter by text in the answers, a submitted time range, response type and response IDs. Responses from the last ~30 minutes may be missing; use the New Submission trigger for real-time data. Page Size up to 1000; for the next page pass the last token as Before. Read-only.', + idempotent: true, + }, + outputSchema: responsesOutputSchema, + props: { + form_id: typeformCommon.form, + query: Property.ShortText({ + displayName: 'Search', + description: 'Only responses containing this text in any answer or hidden field.', + required: false, + }), + since: Property.DateTime({ + displayName: 'Submitted After', + required: false, + }), + until: Property.DateTime({ + displayName: 'Submitted Before', + required: false, + }), + responseType: Property.StaticDropdown({ + displayName: 'Response Type', + required: false, + options: { + options: [ + { label: 'Completed', value: 'completed' }, + { label: 'Partial', value: 'partial' }, + { label: 'Started', value: 'started' }, + ], + }, + }), + includedResponseIds: Property.Array({ + displayName: 'Response IDs', + description: 'Only these responses.', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort', + required: false, + options: { + options: [ + { label: 'Submitted, newest first', value: 'submitted_at,desc' }, + { label: 'Submitted, oldest first', value: 'submitted_at,asc' }, + ], + }, + }), + pageSize: Property.Number({ + displayName: 'Page Size', + description: 'How many responses to return, from 1 to 1000. Default 25.', + required: false, + }), + before: Property.ShortText({ + displayName: 'Before', + description: 'Token of a response. Returns responses older than it, for the next page.', + required: false, + }), + after: Property.ShortText({ + displayName: 'After', + description: 'Token of a response. Returns responses newer than it.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const ids = typeformCommon.toStringList({ values: propsValue.includedResponseIds }); + return typeformCommon.typeformRequest>({ + token: auth.access_token, + method: HttpMethod.GET, + path: `/forms/${encodeURIComponent(propsValue.form_id)}/responses`, + queryParams: { + query: propsValue.query?.trim(), + since: propsValue.since, + until: propsValue.until, + response_type: propsValue.responseType, + included_response_ids: ids.length > 0 ? ids.join(',') : undefined, + sort: propsValue.sort, + before: propsValue.before?.trim(), + after: propsValue.after?.trim(), + page_size: typeformCommon.pageQuery({ page: undefined, pageSize: propsValue.pageSize, maxPageSize: 1000 })['page_size'], + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/list-themes.ts b/packages/pieces/community/typeform/src/lib/actions/list-themes.ts new file mode 100644 index 000000000000..f3a5c51a4d4b --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/list-themes.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformPage, TypeformRecord } from '../common'; +import { themesOutputSchema } from '../output-schemas'; + +export const listThemesAction = createAction({ + auth: typeformAuth, + name: 'list_themes', + classification: 'SEARCH', + displayName: 'List Themes', + description: 'Lists themes available to the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'List Typeform themes with each ID, name, font, colors and visibility: private themes belong to the account and can be edited, public ones are Typeform presets. Paginated with Page and Page Size (up to 200). Read-only.', + idempotent: true, + }, + outputSchema: themesOutputSchema, + props: { + page: typeformCommon.page, + pageSize: typeformCommon.pageSize, + }, + async run({ auth, propsValue }) { + return typeformCommon.typeformRequest>({ + token: auth.access_token, + method: HttpMethod.GET, + path: '/themes', + queryParams: typeformCommon.pageQuery({ + page: propsValue.page, + pageSize: propsValue.pageSize, + maxPageSize: typeformCommon.maxPageSize, + }), + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/list-workspaces.ts b/packages/pieces/community/typeform/src/lib/actions/list-workspaces.ts new file mode 100644 index 000000000000..c0f35cbf0b3d --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/list-workspaces.ts @@ -0,0 +1,48 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformPage, TypeformRecord } from '../common'; +import { workspacesOutputSchema } from '../output-schemas'; + +export const listWorkspacesAction = createAction({ + auth: typeformAuth, + name: 'list_workspaces', + classification: 'SEARCH', + displayName: 'List Workspaces', + description: 'Lists workspaces in the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'List Typeform workspaces with each ID, name, account ID, whether it is the default or shared workspace, and its form count. This is where the account ID for Create Workspace comes from. Filter by name, or by Account ID to list one account. Paginated with Page and Page Size (up to 200). Read-only.', + idempotent: true, + }, + outputSchema: workspacesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only workspaces whose name contains this text.', + required: false, + }), + accountId: Property.ShortText({ + displayName: 'Account ID', + description: 'Only workspaces of this account (account_id from a workspace).', + required: false, + }), + page: typeformCommon.page, + pageSize: typeformCommon.pageSize, + }, + async run({ auth, propsValue }) { + const { search, accountId, page, pageSize } = propsValue; + return typeformCommon.typeformRequest>({ + token: auth.access_token, + method: HttpMethod.GET, + path: typeformCommon.isProvided(accountId) + ? `/accounts/${encodeURIComponent(accountId.trim())}/workspaces` + : '/workspaces', + queryParams: { + search: search?.trim(), + ...typeformCommon.pageQuery({ page, pageSize, maxPageSize: typeformCommon.maxPageSize }), + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/replace-form.ts b/packages/pieces/community/typeform/src/lib/actions/replace-form.ts new file mode 100644 index 000000000000..0d064061f090 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/replace-form.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const replaceFormAction = createAction({ + auth: typeformAuth, + name: 'replace_form', + classification: 'DESTRUCTIVE', + displayName: 'Replace Form', + description: "Replaces a form's whole definition, including its questions.", + audience: 'ai', + aiMetadata: { + description: + "Replace a Typeform form's whole definition (title, fields, screens, logic, settings) with the JSON you send. Call Get Form first and edit that result: any existing field missing from the definition, or sent without its original id, is deleted together with all its answers. The action refuses to delete fields unless Allow Field Deletion is on. The current theme is kept when the definition has none. For title, theme, workspace or public status use Update Form instead.", + idempotent: true, + }, + outputSchema: formOutputSchema, + props: { + form_id: typeformCommon.formId, + definition: Property.Json({ + displayName: 'Form Definition', + description: 'The full form JSON from Get Form, with your changes. Must include title.', + required: true, + }), + allowFieldDeletion: Property.Checkbox({ + displayName: 'Allow Field Deletion', + description: 'Allow removing existing questions. Their responses are deleted too.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + const { form_id, definition, allowFieldDeletion } = propsValue; + if (!typeformCommon.isRecord(definition)) { + throw new Error('Form Definition must be a JSON object.'); + } + if (typeof definition['title'] !== 'string' || definition['title'].trim().length === 0) { + throw new Error('Form Definition must include a title.'); + } + const current = await typeformCommon.getForm({ token: auth.access_token, formId: form_id }); + const keptIds = typeformCommon.fieldIds({ fields: definition['fields'] }); + const removed = typeformCommon + .flattenFields({ fields: current['fields'] }) + .filter((field) => typeof field['id'] === 'string' && !keptIds.includes(field['id'])); + if (removed.length > 0 && allowFieldDeletion !== true) { + const names = removed.map((field) => `"${String(field['title'] ?? field['id'])}" (${String(field['id'])})`); + throw new Error( + `This would delete ${removed.length} question(s) and their responses: ${names.join(', ')}. Keep their ids in the definition, or turn on Allow Field Deletion.` + ); + } + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PUT, + path: `/forms/${encodeURIComponent(form_id)}`, + body: { + ...typeformCommon.toFormPayload({ form: definition }), + ...(definition['theme'] === undefined && current['theme'] !== undefined ? { theme: current['theme'] } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/update-choice-options.ts b/packages/pieces/community/typeform/src/lib/actions/update-choice-options.ts new file mode 100644 index 000000000000..d8767fc92165 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/update-choice-options.ts @@ -0,0 +1,91 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const updateChoiceOptionsAction = createAction({ + auth: typeformAuth, + name: 'update_choice_options', + classification: 'WRITE', + displayName: 'Update Choice Options', + description: 'Replaces the options of a dropdown, multiple choice or ranking question.', + audience: 'both', + aiMetadata: { + description: + 'Replace the list of options of one dropdown, multiple choice, ranking or picture choice question in a Typeform form; the question is found by field ID or ref from Get Form, including inside question groups. Options whose label already exists keep their id, so their past answers stay linked. The rest of the form is unchanged, but an edit made in Typeform at the same moment can be lost. Returns the updated form.', + idempotent: true, + }, + outputSchema: formOutputSchema, + props: { + form_id: typeformCommon.form, + field: typeformCommon.choiceField, + choices: Property.Array({ + displayName: 'Options', + description: 'The full new list of option labels, in order.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const { form_id, field, choices } = propsValue; + const labels = typeformCommon.toStringList({ values: choices }); + if (labels.length === 0) { + throw new Error('Options must contain at least one label.'); + } + const form = await typeformCommon.getForm({ token: auth.access_token, formId: form_id }); + const target = typeformCommon + .flattenFields({ fields: form['fields'] }) + .find((item) => item['id'] === field.trim() || item['ref'] === field.trim()); + if (target === undefined) { + throw new Error(`No question with field ID or ref "${field}" in this form.`); + } + if (!typeformCommon.choiceFieldTypes.includes(String(target['type']))) { + throw new Error(`Question "${String(target['title'])}" is a ${String(target['type'])} question, not a choice question.`); + } + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PUT, + path: `/forms/${encodeURIComponent(form_id)}`, + body: typeformCommon.toFormPayload({ + form: { + ...form, + fields: replaceChoices({ fields: form['fields'], targetId: target['id'], labels }), + }, + }), + }); + }, +}); + +function replaceChoices({ + fields, + targetId, + labels, +}: { + fields: unknown; + targetId: unknown; + labels: string[]; +}): unknown { + if (!Array.isArray(fields)) { + return fields; + } + return fields.map((field) => { + if (!typeformCommon.isRecord(field) || !typeformCommon.isRecord(field['properties'])) { + return field; + } + const properties = field['properties']; + if (field['id'] === targetId) { + const existing = Array.isArray(properties['choices']) ? properties['choices'].filter(typeformCommon.isRecord) : []; + return { + ...field, + properties: { + ...properties, + choices: labels.map((label) => existing.find((choice) => choice['label'] === label) ?? { label }), + }, + }; + } + if (Array.isArray(properties['fields'])) { + return { ...field, properties: { ...properties, fields: replaceChoices({ fields: properties['fields'], targetId, labels }) } }; + } + return field; + }); +} diff --git a/packages/pieces/community/typeform/src/lib/actions/update-form-messages.ts b/packages/pieces/community/typeform/src/lib/actions/update-form-messages.ts new file mode 100644 index 000000000000..427c123804dd --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/update-form-messages.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; + +export const updateFormMessagesAction = createAction({ + auth: typeformAuth, + name: 'update_form_messages', + classification: 'WRITE', + displayName: 'Update Form Messages', + description: 'Changes button labels, error messages and other texts of a form.', + audience: 'ai', + aiMetadata: { + description: + 'Change some of the interface texts of a Typeform form. Send only the keys to change, as a JSON object like {"label.button.submit":"Send"}; keys come from Get Form Messages and the other texts stay as they are. Plain text only, no HTML. Returns all messages after the change.', + idempotent: true, + }, + props: { + form_id: typeformCommon.formId, + messages: Property.Json({ + displayName: 'Messages', + description: 'JSON object of message keys and their new text.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const { form_id, messages } = propsValue; + if (!typeformCommon.isRecord(messages) || Object.keys(messages).length === 0) { + throw new Error('Messages must be a JSON object with at least one key.'); + } + const path = `/forms/${encodeURIComponent(form_id)}/messages`; + const current = await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path, + }); + const merged = { ...current, ...messages }; + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PUT, + path, + body: merged, + }); + return merged; + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/update-form.ts b/packages/pieces/community/typeform/src/lib/actions/update-form.ts new file mode 100644 index 000000000000..0c001f3211e0 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/update-form.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon } from '../common'; +import { formOutputSchema } from '../output-schemas'; + +export const updateFormAction = createAction({ + auth: typeformAuth, + name: 'update_form', + classification: 'WRITE', + displayName: 'Update Form', + description: "Changes a form's title, public status, theme or workspace.", + audience: 'ai', + aiMetadata: { + description: + "Change a Typeform form's title, public status (whether it accepts responses), theme or workspace. Only the fields you set change; questions are untouched. To change questions use Replace Form or Update Choice Options. Returns the updated form.", + idempotent: true, + }, + outputSchema: formOutputSchema, + props: { + form_id: typeformCommon.formId, + title: Property.ShortText({ + displayName: 'Title', + required: false, + }), + isPublic: Property.StaticDropdown({ + displayName: 'Public', + description: 'Whether the form is open and accepts responses.', + required: false, + options: { + options: [ + { label: 'Public', value: 'true' }, + { label: 'Closed', value: 'false' }, + ], + }, + }), + theme: typeformCommon.themeId, + workspace: typeformCommon.workspaceId, + }, + async run({ auth, propsValue }) { + const { form_id, title, isPublic, theme, workspace } = propsValue; + const operations = [ + ...(typeformCommon.isProvided(title) ? [{ op: 'replace', path: '/title', value: title }] : []), + ...(typeformCommon.isProvided(isPublic) ? [{ op: 'replace', path: '/settings/is_public', value: isPublic === 'true' }] : []), + ...(typeformCommon.isProvided(theme) + ? [{ op: 'replace', path: '/theme', value: typeformCommon.toHref({ resource: 'themes', id: theme }) }] + : []), + ...(typeformCommon.isProvided(workspace) + ? [{ op: 'replace', path: '/workspace', value: typeformCommon.toHref({ resource: 'workspaces', id: workspace }) }] + : []), + ]; + if (operations.length === 0) { + throw new Error('Set at least one of Title, Public, Theme or Workspace.'); + } + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PATCH, + path: `/forms/${encodeURIComponent(form_id)}`, + body: operations, + }); + return typeformCommon.getForm({ token: auth.access_token, formId: form_id }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/update-theme.ts b/packages/pieces/community/typeform/src/lib/actions/update-theme.ts new file mode 100644 index 000000000000..647bd2963acc --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/update-theme.ts @@ -0,0 +1,51 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { themeOutputSchema } from '../output-schemas'; + +const THEME_KEYS = ['name', 'font', 'colors', 'fields', 'screens', 'background', 'has_transparent_button', 'rounded_corners']; + +export const updateThemeAction = createAction({ + auth: typeformAuth, + name: 'update_theme', + classification: 'WRITE', + displayName: 'Update Theme', + description: 'Changes some settings of a custom theme.', + audience: 'ai', + aiMetadata: { + description: + "Change a private Typeform theme's name, font, colors, alignment or font sizes; only the settings you set change. Typeform's public preset themes cannot be edited. All forms using the theme change with it. Returns the updated theme.", + idempotent: true, + }, + outputSchema: themeOutputSchema, + props: { + themeId: typeformCommon.requiredThemeId, + ...typeformCommon.themeFields, + }, + async run({ auth, propsValue }) { + const path = `/themes/${encodeURIComponent(propsValue.themeId.trim())}`; + const current = await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path, + }); + if (current['visibility'] === 'public') { + throw new Error("This is one of Typeform's public themes and cannot be edited. Create your own with Create Theme."); + } + const changes = typeformCommon.themeBody({ values: propsValue, current }); + if (Object.keys(changes).length === 0) { + throw new Error('Set at least one setting to change.'); + } + const body = { + ...Object.fromEntries(Object.entries(current).filter(([key]) => THEME_KEYS.includes(key))), + ...changes, + }; + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PATCH, + path, + body, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/update-workspace.ts b/packages/pieces/community/typeform/src/lib/actions/update-workspace.ts new file mode 100644 index 000000000000..f18d9d05b810 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/update-workspace.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { workspaceOutputSchema } from '../output-schemas'; + +export const updateWorkspaceAction = createAction({ + auth: typeformAuth, + name: 'update_workspace', + classification: 'WRITE', + displayName: 'Rename Workspace', + description: 'Renames a workspace.', + audience: 'ai', + aiMetadata: { + description: + 'Rename a Typeform workspace. Its forms and members are unchanged. Returns the updated workspace.', + idempotent: true, + }, + outputSchema: workspaceOutputSchema, + props: { + workspace: typeformCommon.requiredWorkspaceId, + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + }, + async run({ auth, propsValue }) { + const { workspace, name } = propsValue; + const path = `/workspaces/${encodeURIComponent(workspace)}`; + await typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.PATCH, + path, + body: [{ op: 'replace', path: '/name', value: name }], + }); + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.GET, + path, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/actions/upload-image.ts b/packages/pieces/community/typeform/src/lib/actions/upload-image.ts new file mode 100644 index 000000000000..59fbfa5cb866 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/actions/upload-image.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { typeformAuth } from '../auth'; +import { typeformCommon, TypeformRecord } from '../common'; +import { imageOutputSchema } from '../output-schemas'; + +export const uploadImageAction = createAction({ + auth: typeformAuth, + name: 'upload_image', + classification: 'WRITE', + displayName: 'Upload Image', + description: 'Uploads an image to the Typeform account.', + audience: 'ai', + aiMetadata: { + description: + 'Upload an image to the Typeform account from a file or a public URL, to use in forms and themes. Returns the new image with its ID and link. Each call uploads another copy.', + idempotent: false, + }, + outputSchema: imageOutputSchema, + props: { + image: Property.File({ + displayName: 'Image', + description: 'The image file, or a public URL to it.', + required: true, + }), + fileName: Property.ShortText({ + displayName: 'File Name', + description: "Defaults to the file's own name.", + required: false, + }), + }, + async run({ auth, propsValue }) { + const { image, fileName } = propsValue; + return typeformCommon.typeformRequest({ + token: auth.access_token, + method: HttpMethod.POST, + path: '/images', + body: { + file_name: typeformCommon.isProvided(fileName) ? fileName.trim() : image.filename, + image: image.base64, + }, + }); + }, +}); diff --git a/packages/pieces/community/typeform/src/lib/auth.ts b/packages/pieces/community/typeform/src/lib/auth.ts new file mode 100644 index 000000000000..c6c6d2669c37 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/auth.ts @@ -0,0 +1,22 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; + +export const typeformAuth = PieceAuth.OAuth2({ + required: true, + tokenUrl: 'https://api.typeform.com/oauth/token', + authUrl: 'https://admin.typeform.com/oauth/authorize', + scope: [ + 'webhooks:write', + 'forms:read', + 'forms:write', + 'accounts:read', + 'responses:read', + 'responses:write', + 'workspaces:read', + 'workspaces:write', + 'themes:read', + 'themes:write', + 'images:read', + 'images:write', + 'offline', + ], +}); diff --git a/packages/pieces/community/typeform/src/lib/common/index.ts b/packages/pieces/community/typeform/src/lib/common/index.ts index abca58029aed..555351f95b16 100644 --- a/packages/pieces/community/typeform/src/lib/common/index.ts +++ b/packages/pieces/community/typeform/src/lib/common/index.ts @@ -1,25 +1,285 @@ import { Property, - OAuth2PropertyValue, DropdownOption, - AppConnectionValueForAuthProperty, + DropdownState, } from '@activepieces/pieces-framework'; import { HttpRequest, HttpMethod, + HttpError, AuthenticationType, httpClient, } from '@activepieces/pieces-common'; -import { typeformAuth } from '../..'; +import { typeformAuth } from '../auth'; -type FormListResponse = { - page_count: number; - total_items: number; - items: { - id: string; - title: string; - }[]; -}; +async function typeformRequest({ + token, + method, + path, + queryParams, + body, + headers, +}: TypeformRequest): Promise { + const response = await httpClient + .sendRequest({ + method, + url: `${BASE_URL}${path}`, + queryParams: compactQuery({ query: queryParams ?? {} }), + body, + headers, + authentication: { type: AuthenticationType.BEARER_TOKEN, token }, + }) + .catch((error: unknown) => { + throw toTypeformError({ error }); + }); + return response.body; +} + +async function fetchAllPages({ + token, + path, + queryParams, +}: { + token: string; + path: string; + queryParams?: Record; +}): Promise { + const fetchPage = async (page: number): Promise => { + const response = await typeformRequest>({ + token, + method: HttpMethod.GET, + path, + queryParams: { ...queryParams, page: String(page), page_size: String(MAX_PAGE_SIZE) }, + }); + const items = response.items ?? []; + if (response.page_count === undefined || page >= response.page_count) { + return items; + } + return [...items, ...(await fetchPage(page + 1))]; + }; + return fetchPage(1); +} + +async function getForm({ token, formId }: { token: string; formId: string }): Promise { + return typeformRequest({ + token, + method: HttpMethod.GET, + path: `/forms/${encodeURIComponent(formId)}`, + }); +} + +function pageQuery({ + page, + pageSize, + maxPageSize, +}: { + page: number | undefined; + pageSize: number | undefined; + maxPageSize: number; +}): Record { + return { + page: page === undefined || page === null ? undefined : String(Math.max(1, Math.floor(page))), + page_size: + pageSize === undefined || pageSize === null + ? undefined + : String(Math.min(maxPageSize, Math.max(1, Math.floor(pageSize)))), + }; +} + +function toHref({ resource, id }: { resource: string; id: string }): { href: string } { + return { href: `${BASE_URL}/${resource}/${encodeURIComponent(id.trim())}` }; +} + +function toFormPayload({ form }: { form: TypeformRecord }): TypeformRecord { + const rest = omitKeys({ record: form, keys: READ_ONLY_FORM_KEYS }); + return { + ...rest, + ...(isRecord(rest['settings']) ? { settings: withoutReadOnlySettings({ settings: rest['settings'] }) } : {}), + }; +} + +function toDuplicatePayload({ form }: { form: TypeformRecord }): TypeformRecord { + const payload = toFormPayload({ form }); + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + DUPLICATE_ID_KEYS.includes(key) ? stripIds({ value }) : value, + ]) + ); +} + +function stripIds({ value }: { value: unknown }): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripIds({ value: item })); + } + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== 'id') + .map(([key, item]) => [key, stripIds({ value: item })]) + ); +} + +function withoutReadOnlySettings({ settings }: { settings: TypeformRecord }): TypeformRecord { + const enrichment = settings['enrichment_in_renderer']; + if (!isRecord(enrichment)) { + return settings; + } + return { ...settings, enrichment_in_renderer: omitKeys({ record: enrichment, keys: ['active'] }) }; +} + +function omitKeys({ record, keys }: { record: TypeformRecord; keys: string[] }): TypeformRecord { + return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key))); +} + +function flattenFields({ fields }: { fields: unknown }): TypeformRecord[] { + if (!Array.isArray(fields)) { + return []; + } + return fields.filter(isRecord).flatMap((field) => { + const properties = field['properties']; + const nested = isRecord(properties) ? flattenFields({ fields: properties['fields'] }) : []; + return [field, ...nested]; + }); +} + +function fieldIds({ fields }: { fields: unknown }): string[] { + return flattenFields({ fields }) + .map((field) => field['id']) + .filter((id): id is string => typeof id === 'string'); +} + +function themeBody({ values, current }: { values: ThemeFieldValues; current: TypeformRecord }): TypeformRecord { + const colors = compactRecord({ + record: { + question: values.questionColor, + answer: values.answerColor, + button: values.buttonColor, + background: values.backgroundColor, + }, + }); + const fields = compactRecord({ record: { alignment: values.fieldAlignment, font_size: values.fieldFontSize } }); + const screens = compactRecord({ record: { alignment: values.screenAlignment, font_size: values.screenFontSize } }); + return { + ...(isProvided(values.name) ? { name: values.name.trim() } : {}), + ...(isProvided(values.font) ? { font: values.font } : {}), + ...(Object.keys(colors).length > 0 ? { colors: { ...recordOrEmpty({ value: current['colors'] }), ...colors } } : {}), + ...(Object.keys(fields).length > 0 ? { fields: { ...recordOrEmpty({ value: current['fields'] }), ...fields } } : {}), + ...(Object.keys(screens).length > 0 ? { screens: { ...recordOrEmpty({ value: current['screens'] }), ...screens } } : {}), + }; +} + +function compactRecord({ record }: { record: Record }): Record { + return Object.fromEntries( + Object.entries(record) + .filter((entry): entry is [string, string] => isProvided(entry[1])) + .map(([key, value]) => [key, value.trim()]) + ); +} + +function recordOrEmpty({ value }: { value: unknown }): TypeformRecord { + return isRecord(value) ? value : {}; +} + +function isProvided(value: string | undefined | null): value is string { + return value !== undefined && value !== null && value.trim().length > 0; +} + +function isRecord(value: unknown): value is TypeformRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toStringList({ values }: { values: unknown[] | undefined | null }): string[] { + return (values ?? []) + .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number') + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); +} + +function compactQuery({ + query, +}: { + query: Record; +}): Record { + return Object.fromEntries( + Object.entries(query).filter( + (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== '' + ) + ); +} + +function toTypeformError({ error }: { error: unknown }): unknown { + if (!(error instanceof HttpError)) { + return error; + } + const { status, body } = error.response; + if (status === 403) { + return new Error( + 'Typeform denied access (403). Reconnect your Typeform account to grant the permissions this action needs, and check that your plan includes this feature.' + ); + } + if (!isRecord(body)) { + return error; + } + const code = typeof body['code'] === 'string' ? body['code'] : 'Typeform error'; + const description = typeof body['description'] === 'string' ? body['description'] : ''; + const details = Array.isArray(body['details']) ? ` ${JSON.stringify(body['details'])}` : ''; + return new Error(`${code} (${status}): ${description}${details}`.trim()); +} + +async function workspaceOptions({ token }: { token: string | undefined }): Promise> { + if (!token) { + return { disabled: true, placeholder: 'Connect your Typeform account', options: [] }; + } + const workspaces = await fetchAllPages<{ id: string; name: string }>({ + token, + path: '/workspaces', + }); + return { + disabled: false, + options: workspaces.map((workspace) => ({ label: workspace.name, value: workspace.id })), + }; +} + +async function themeOptions({ token }: { token: string | undefined }): Promise> { + if (!token) { + return { disabled: true, placeholder: 'Connect your Typeform account', options: [] }; + } + const themes = await fetchAllPages<{ id: string; name: string; visibility: string }>({ + token, + path: '/themes', + }); + return { + disabled: false, + options: themes.map((theme) => ({ + label: theme.visibility === 'public' ? `${theme.name} (Typeform)` : theme.name, + value: theme.id, + })), + }; +} + +const BASE_URL = 'https://api.typeform.com'; +const MAX_PAGE_SIZE = 200; +const READ_ONLY_FORM_KEYS = ['id', '_links', 'self', 'created_at', 'last_updated_at', 'published_at']; +const DUPLICATE_ID_KEYS = ['fields', 'welcome_screens', 'thankyou_screens', 'logic', 'variables', 'hidden']; +const THEME_FONTS = [ + 'Acme', 'Arial', 'Arvo', 'Avenir Next', 'Bangers', 'Cabin', 'Cabin Condensed', 'Courier', 'Crete Round', + 'Dancing Script', 'Exo', 'Georgia', 'Handlee', 'Helvetica Neue', 'Karla', 'Lato', 'Lekton', 'Lobster', 'Lora', + 'McLaren', 'Montserrat', 'Nixie One', 'Old Standard TT', 'Open Sans', 'Oswald', 'Playfair Display', 'Quicksand', + 'Raleway', 'Signika', 'Sniglet', 'Source Sans Pro', 'Vollkorn', +]; +const ALIGNMENTS = [ + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, +]; +const FONT_SIZES = [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, +]; +const CHOICE_FIELD_TYPES = ['dropdown', 'multiple_choice', 'ranking', 'picture_choice']; export const formsDropdown = Property.Dropdown({ auth: typeformAuth, @@ -80,7 +340,7 @@ export const formsDropdown = Property.Dropdown({ + auth: typeformAuth, + displayName: 'Workspace', + description: 'Workspace ID, from List Workspaces.', + required: false, + refreshers: [], + options: async ({ auth }) => workspaceOptions({ token: auth?.access_token }), + }), + theme: Property.Dropdown({ + auth: typeformAuth, + displayName: 'Theme', + description: 'Theme ID, from List Themes.', + required: false, + refreshers: [], + options: async ({ auth }) => themeOptions({ token: auth?.access_token }), + }), + choiceField: Property.Dropdown({ + auth: typeformAuth, + displayName: 'Question', + description: 'Dropdown, multiple choice, ranking or picture choice question. Field ID or ref, from Get Form.', + required: true, + refreshers: ['form_id'], + async options({ auth, form_id }) { + if (!auth || typeof form_id !== 'string' || form_id.length === 0) { + return { disabled: true, placeholder: 'Select a form first', options: [] }; + } + const form = await getForm({ token: auth.access_token, formId: form_id }); + return { + disabled: false, + options: flattenFields({ fields: form['fields'] }) + .filter((field) => CHOICE_FIELD_TYPES.includes(String(field['type']))) + .map((field) => ({ label: String(field['title'] ?? field['id']), value: String(field['id']) })), + }; + }, + }), + formId: Property.ShortText({ + displayName: 'Form ID', + description: 'Form ID, from List Forms.', + required: true, + }), + workspaceId: Property.ShortText({ + displayName: 'Workspace ID', + description: 'Workspace ID, from List Workspaces.', + required: false, + }), + requiredWorkspaceId: Property.ShortText({ + displayName: 'Workspace ID', + description: 'Workspace ID, from List Workspaces.', + required: true, + }), + themeId: Property.ShortText({ + displayName: 'Theme ID', + description: 'Theme ID, from List Themes.', + required: false, + }), + requiredThemeId: Property.ShortText({ + displayName: 'Theme ID', + description: 'Theme ID, from List Themes.', + required: true, + }), + themeFields: { + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + font: Property.StaticDropdown({ + displayName: 'Font', + required: false, + options: { options: THEME_FONTS.map((font) => ({ label: font, value: font })) }, + }), + questionColor: Property.ShortText({ + displayName: 'Question Color', + description: 'Hex color, for example #3D3D3D.', + required: false, + }), + answerColor: Property.ShortText({ + displayName: 'Answer Color', + description: 'Hex color.', + required: false, + }), + buttonColor: Property.ShortText({ + displayName: 'Button Color', + description: 'Hex color.', + required: false, + }), + backgroundColor: Property.ShortText({ + displayName: 'Background Color', + description: 'Hex color.', + required: false, + }), + fieldAlignment: Property.StaticDropdown({ + displayName: 'Question Alignment', + required: false, + options: { options: ALIGNMENTS }, + }), + fieldFontSize: Property.StaticDropdown({ + displayName: 'Question Font Size', + required: false, + options: { options: FONT_SIZES }, + }), + screenAlignment: Property.StaticDropdown({ + displayName: 'Screen Alignment', + description: 'Alignment of welcome and thank-you screens.', + required: false, + options: { options: ALIGNMENTS }, + }), + screenFontSize: Property.StaticDropdown({ + displayName: 'Screen Font Size', + required: false, + options: { options: FONT_SIZES }, + }), + }, + page: Property.Number({ + displayName: 'Page', + description: 'Page number, starting at 1.', + required: false, + }), + pageSize: Property.Number({ + displayName: 'Page Size', + description: 'How many items to return per page, from 1 to 200.', + required: false, + }), + typeformRequest, + fetchAllPages, + getForm, + pageQuery, + toHref, + toFormPayload, + toDuplicatePayload, + flattenFields, + fieldIds, + themeBody, + isProvided, + isRecord, + toStringList, + maxPageSize: MAX_PAGE_SIZE, + choiceFieldTypes: CHOICE_FIELD_TYPES, +}; + +export type TypeformRecord = Record; + +export type TypeformPage = { + total_items?: number; + page_count?: number; + items?: T[]; +}; + +type ThemeFieldValues = { + name?: string | null; + font?: string | null; + questionColor?: string | null; + answerColor?: string | null; + buttonColor?: string | null; + backgroundColor?: string | null; + fieldAlignment?: string | null; + fieldFontSize?: string | null; + screenAlignment?: string | null; + screenFontSize?: string | null; +}; + +type FormListResponse = { + page_count: number; + total_items: number; + items: { + id: string; + title: string; + }[]; +}; + +type TypeformRequest = { + token: string; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: unknown; + headers?: Record; }; diff --git a/packages/pieces/community/typeform/src/lib/output-schemas.ts b/packages/pieces/community/typeform/src/lib/output-schemas.ts new file mode 100644 index 000000000000..c49e718a0040 --- /dev/null +++ b/packages/pieces/community/typeform/src/lib/output-schemas.ts @@ -0,0 +1,258 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const pageFields: OutputSchemaField[] = [ + { key: 'total_items', label: 'Total Items', format: 'number' }, + { key: 'page_count', label: 'Page Count', format: 'number' }, +]; + +const choiceFields: OutputSchemaField[] = [ + { key: 'id', label: 'Choice ID' }, + { key: 'ref', label: 'Choice Ref' }, + { key: 'label', label: 'Label' }, +]; + +const questionFields: OutputSchemaField[] = [ + { key: 'id', label: 'Field ID' }, + { key: 'ref', label: 'Ref' }, + { key: 'title', label: 'Question' }, + { key: 'type', label: 'Type' }, + { key: 'validations_required', value: 'validations.required', label: 'Required', format: 'boolean' }, + { key: 'properties_choices', value: 'properties.choices', label: 'Choices', labelKey: 'label', listItems: choiceFields }, +]; + +const formFields: OutputSchemaField[] = [ + { key: 'id', label: 'Form ID' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Type' }, + { key: 'links_display', value: '_links.display', label: 'Share Link', format: 'url' }, + { key: 'settings_is_public', value: 'settings.is_public', label: 'Public', format: 'boolean' }, + { key: 'settings_language', value: 'settings.language', label: 'Language' }, + { key: 'workspace_href', value: 'workspace.href', label: 'Workspace', format: 'url' }, + { key: 'theme_href', value: 'theme.href', label: 'Theme', format: 'url' }, + { + key: 'fields', + label: 'Questions', + labelKey: 'title', + listItems: [ + ...questionFields, + { key: 'properties_fields', value: 'properties.fields', label: 'Group Questions', labelKey: 'title', listItems: questionFields }, + ], + }, + { key: 'hidden', label: 'Hidden Fields' }, + { + key: 'thankyou_screens', + label: 'Thank-you Screens', + labelKey: 'title', + listItems: [ + { key: 'ref', label: 'Ref' }, + { key: 'title', label: 'Title' }, + ], + }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'last_updated_at', label: 'Last Updated At', format: 'datetime' }, + { key: 'published_at', label: 'Published At', format: 'datetime' }, +]; + +const formSummaryFields: OutputSchemaField[] = [ + { key: 'id', label: 'Form ID' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Type' }, + { key: 'links_display', value: '_links.display', label: 'Share Link', format: 'url' }, + { key: 'settings_is_public', value: 'settings.is_public', label: 'Public', format: 'boolean' }, + { key: 'theme_href', value: 'theme.href', label: 'Theme', format: 'url' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'last_updated_at', label: 'Last Updated At', format: 'datetime' }, +]; + +const answerFields: OutputSchemaField[] = [ + { key: 'field_id', value: 'field.id', label: 'Field ID' }, + { key: 'field_ref', value: 'field.ref', label: 'Field Ref' }, + { key: 'field_type', value: 'field.type', label: 'Question Type' }, + { key: 'type', label: 'Answer Type' }, + { key: 'text', label: 'Text' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'number', label: 'Number', format: 'number' }, + { key: 'boolean', label: 'Yes/No', format: 'boolean' }, + { key: 'date', label: 'Date', format: 'date' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'phone_number', label: 'Phone Number' }, + { key: 'file_url', label: 'File', format: 'url' }, + { key: 'choice_label', value: 'choice.label', label: 'Choice' }, + { key: 'choices_labels', value: 'choices.labels', label: 'Choices' }, +]; + +const responseFields: OutputSchemaField[] = [ + { key: 'response_id', label: 'Response ID' }, + { key: 'token', label: 'Token' }, + { key: 'response_type', label: 'Response Type' }, + { key: 'submitted_at', label: 'Submitted At', format: 'datetime' }, + { key: 'landed_at', label: 'Landed At', format: 'datetime' }, + { key: 'answers', label: 'Answers', labelKey: 'field.ref', listItems: answerFields }, + { key: 'hidden', label: 'Hidden Fields' }, + { key: 'calculated_score', value: 'calculated.score', label: 'Score', format: 'number' }, + { key: 'metadata_referer', value: 'metadata.referer', label: 'Referrer', format: 'url' }, + { key: 'metadata_platform', value: 'metadata.platform', label: 'Platform' }, + { key: 'thankyou_screen_ref', label: 'Thank-you Screen Ref' }, +]; + +const workspaceFields: OutputSchemaField[] = [ + { key: 'id', label: 'Workspace ID' }, + { key: 'name', label: 'Name' }, + { key: 'account_id', label: 'Account ID' }, + { key: 'default', label: 'Default', format: 'boolean' }, + { key: 'shared', label: 'Shared', format: 'boolean' }, + { key: 'forms_count', value: 'forms.count', label: 'Form Count', format: 'number' }, +]; + +const workspaceDetailFields: OutputSchemaField[] = [ + ...workspaceFields, + { + key: 'members', + label: 'Members', + labelKey: 'email', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'role', label: 'Role' }, + ], + }, +]; + +const themeFields: OutputSchemaField[] = [ + { key: 'id', label: 'Theme ID' }, + { key: 'name', label: 'Name' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'font', label: 'Font' }, + { key: 'colors_question', value: 'colors.question', label: 'Question Color' }, + { key: 'colors_answer', value: 'colors.answer', label: 'Answer Color' }, + { key: 'colors_button', value: 'colors.button', label: 'Button Color' }, + { key: 'colors_background', value: 'colors.background', label: 'Background Color' }, + { key: 'fields_alignment', value: 'fields.alignment', label: 'Question Alignment' }, + { key: 'fields_font_size', value: 'fields.font_size', label: 'Question Font Size' }, + { key: 'screens_alignment', value: 'screens.alignment', label: 'Screen Alignment' }, + { key: 'screens_font_size', value: 'screens.font_size', label: 'Screen Font Size' }, + { key: 'has_transparent_button', label: 'Transparent Button', format: 'boolean' }, + { key: 'rounded_corners', label: 'Rounded Corners' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const imageFields: OutputSchemaField[] = [ + { key: 'id', label: 'Image ID' }, + { key: 'file_name', label: 'File Name' }, + { key: 'src', label: 'Image', format: 'image' }, + { key: 'media_type', label: 'Media Type' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, +]; + +export const accountOutputSchema: OutputSchema = { + fields: [ + { key: 'user_id', label: 'User ID' }, + { key: 'alias', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'language', label: 'Language' }, + ], +}; + +export const formsOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Forms', labelKey: 'title', listItems: formSummaryFields }, ...pageFields], +}; + +export const formOutputSchema: OutputSchema = { + fields: formFields, +}; + +export const deleteFormOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'form_id', label: 'Form ID' }, + ], +}; + +export const responsesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Responses', labelKey: 'submitted_at', listItems: responseFields }, ...pageFields], +}; + +export const deleteResponsesOutputSchema: OutputSchema = { + fields: [ + { key: 'registered', label: 'Deletion Requested', format: 'boolean' }, + { key: 'form_id', label: 'Form ID' }, + { key: 'response_ids', label: 'Response IDs' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const workspacesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Workspaces', labelKey: 'name', listItems: workspaceFields }, ...pageFields], +}; + +export const workspaceOutputSchema: OutputSchema = { + fields: workspaceDetailFields, +}; + +export const deleteWorkspaceOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'workspace_id', label: 'Workspace ID' }, + ], +}; + +export const themesOutputSchema: OutputSchema = { + fields: [{ key: 'items', label: 'Themes', labelKey: 'name', listItems: themeFields }, ...pageFields], +}; + +export const themeOutputSchema: OutputSchema = { + fields: themeFields, +}; + +export const deleteThemeOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'theme_id', label: 'Theme ID' }, + ], +}; + +export const imagesOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Images', labelKey: 'file_name', listItems: imageFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total_items', label: 'Total Images', format: 'number' }, + ], +}; + +export const imageOutputSchema: OutputSchema = { + fields: imageFields, +}; + +export const deleteImageOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'image_id', label: 'Image ID' }, + ], +}; + +export const newSubmissionOutputSchema: OutputSchema = { + fields: [ + { key: 'form_id', label: 'Form ID' }, + { key: 'token', label: 'Response Token' }, + { key: 'submitted_at', label: 'Submitted At', format: 'datetime' }, + { key: 'landed_at', label: 'Landed At', format: 'datetime' }, + { key: 'definition_title', value: 'definition.title', label: 'Form Title' }, + { + key: 'definition_fields', value: 'definition.fields', + label: 'Questions', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'Field ID' }, + { key: 'ref', label: 'Ref' }, + { key: 'title', label: 'Question' }, + { key: 'type', label: 'Type' }, + ], + }, + { key: 'answers', label: 'Answers', labelKey: 'field.ref', listItems: answerFields }, + { key: 'hidden', label: 'Hidden Fields' }, + { key: 'calculated_score', value: 'calculated.score', label: 'Score', format: 'number' }, + { key: 'thankyou_screen_ref', label: 'Thank-you Screen Ref' }, + ], +}; diff --git a/packages/pieces/community/typeform/src/lib/trigger/new-submission.ts b/packages/pieces/community/typeform/src/lib/trigger/new-submission.ts index 9adb0a5b137e..cf8f2c8e5dc0 100644 --- a/packages/pieces/community/typeform/src/lib/trigger/new-submission.ts +++ b/packages/pieces/community/typeform/src/lib/trigger/new-submission.ts @@ -2,7 +2,8 @@ import { typeformCommon, formsDropdown } from '../common'; import { nanoid } from 'nanoid'; import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; import { getAccessTokenOrThrow } from '@activepieces/pieces-common'; -import { typeformAuth } from '../..'; +import { typeformAuth } from '../auth'; +import { newSubmissionOutputSchema } from '../output-schemas'; export const typeformNewSubmission = createTrigger({ auth: typeformAuth, @@ -13,6 +14,7 @@ export const typeformNewSubmission = createTrigger({ description: 'Fires when a respondent completes and submits the specified Typeform form. The event represents a single finished submission, delivered via webhook, and provides the form response payload including the submission token, landing/submission timestamps, the form definition, and the respondent\'s answers.', }, + outputSchema: newSubmissionOutputSchema, props: { form_id: formsDropdown, }, diff --git a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts index 594eebb3f5f9..85872ebbc0ba 100644 --- a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts +++ b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts @@ -80,6 +80,7 @@ const CLOUD_ONLY_BILLING_AND_ONBOARDING_EVENTS: TelemetryEvent[] = [ { name: TelemetryEventName.PLAN_REACTIVATED, payload: { platformId: 'platform-1', plan: 'plus' } }, { name: TelemetryEventName.TRIAL_STARTED, payload: { platformId: 'platform-1', plan: 'plus', trialEndsAt: '2030-01-01T00:00:00.000Z' } }, { name: TelemetryEventName.SALES_HANDOFF_CLICKED, payload: { surface: 'plan-selector' } }, + { name: TelemetryEventName.ADMIN_NAV_LOCKED_CLICKED, payload: { path: '/platform/sso', tier: 'team' } }, ] const capturedEventNames = (): string[] => mockCapture.mock.calls.map(([message]) => message.event) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 020c9857f83c..a6d9e07c205b 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -3083,5 +3083,13 @@ "billing": "billing", "project roles": "project roles", "infrastructure status": "infrastructure status", - "Plans, credits and usage tracking are part of the Enterprise and Cloud editions.": "Plans, credits and usage tracking are part of the Enterprise and Cloud editions." + "Plans, credits and usage tracking are part of the Enterprise and Cloud editions.": "Plans, credits and usage tracking are part of the Enterprise and Cloud editions.", + "Requires a plan upgrade": "Requires a plan upgrade", + "Catalogue": "Catalogue", + "Developers": "Developers", + "Operations": "Operations", + "Forward every audit event we emit to a webhook, then handle it in a flow.": "Forward every audit event we emit to a webhook, then handle it in a flow.", + "Not included in your plan": "Not included in your plan", + "Included in the {tier} plan": "Included in the {tier} plan", + "Opened a paid admin page": "Opened a paid admin page" } diff --git a/packages/web/src/app/builder/pieces-selector/ai-tab-content/ai-actions-list.tsx b/packages/web/src/app/builder/pieces-selector/ai-tab-content/ai-actions-list.tsx index 80b401c9ef84..e8b92bd15828 100644 --- a/packages/web/src/app/builder/pieces-selector/ai-tab-content/ai-actions-list.tsx +++ b/packages/web/src/app/builder/pieces-selector/ai-tab-content/ai-actions-list.tsx @@ -83,7 +83,7 @@ export const AIPieceActionsList: React.FC = ({ action: { label: 'Set Up', onClick: () => { - navigate('/platform/setup/ai'); + navigate('/platform/ai'); }, }, }); diff --git a/packages/web/src/app/components/global-search/static-pages.ts b/packages/web/src/app/components/global-search/static-pages.ts index e46f52582f7c..2e13806d6f7d 100644 --- a/packages/web/src/app/components/global-search/static-pages.ts +++ b/packages/web/src/app/components/global-search/static-pages.ts @@ -74,112 +74,168 @@ export const STATIC_PAGES: StaticPage[] = [ { id: 'page-platform-ai', label: 'Platform Admin — AI Center', - href: '/platform/setup/ai', + href: '/platform/ai', icon: BotIcon, requiresPlatformAdmin: true, }, + { + id: 'page-platform-ai-capabilities', + label: 'Platform Admin — AI Capabilities', + href: '/platform/ai/capabilities', + icon: BotIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-mcp', + label: 'Platform Admin — MCP Server', + href: '/platform/mcp', + icon: ConnectIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-mcp-tools', + label: 'Platform Admin — MCP Tools', + href: '/platform/mcp/tools', + icon: ConnectIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-mcp-activity', + label: 'Platform Admin — MCP Activity', + href: '/platform/mcp/activity', + icon: ConnectIcon, + requiresPlatformAdmin: true, + }, { id: 'page-platform-branding', label: 'Platform Admin — General', - href: '/platform/setup/general', + href: '/platform/general', icon: PaletteIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-connections', label: 'Platform Admin — Global Connections', - href: '/platform/setup/connections', + href: '/platform/connections/global', icon: UnplugIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-pieces', label: 'Platform Admin — Pieces', - href: '/platform/setup/pieces', + href: '/platform/pieces', + icon: PuzzleIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-piece-sets', + label: 'Platform Admin — Piece Sets', + href: '/platform/pieces/piece-sets', icon: PuzzleIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-templates', label: 'Platform Admin — Templates', - href: '/platform/setup/templates', + href: '/platform/templates', icon: LayoutGridIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-billing', label: 'Platform Admin — Billing', - href: '/platform/setup/billing', + href: '/platform/billing', icon: ReceiptIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-embedding', label: 'Platform Admin — Embedding', - href: '/platform/security/embed', + href: '/platform/embedding', icon: FrameIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-audit-logs', label: 'Platform Admin — Audit Logs', - href: '/platform/security/audit-logs', + href: '/platform/audit-log', icon: SquareDashedBottomCodeIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-sso', label: 'Platform Admin — Single Sign On', - href: '/platform/security/sso', + href: '/platform/sso', icon: LogInIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-project-roles', label: 'Platform Admin — Project Roles', - href: '/platform/security/project-roles', + href: '/platform/users/roles', icon: Settings2Icon, requiresPlatformAdmin: true, }, { id: 'page-platform-api-keys', label: 'Platform Admin — API Keys', - href: '/platform/security/api-keys', + href: '/platform/api-keys', icon: FileJson2Icon, requiresPlatformAdmin: true, }, { id: 'page-platform-secret-managers', label: 'Platform Admin — Secret Managers', - href: '/platform/security/secret-managers', + href: '/platform/secret-managers', icon: KeyRoundIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-workers', label: 'Platform Admin — Workers', - href: '/platform/infrastructure/workers', + href: '/platform/workers', + icon: ServerIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-worker-groups', + label: 'Platform Admin — Worker Groups', + href: '/platform/workers/groups', icon: ServerIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-health', label: 'Platform Admin — Health', - href: '/platform/infrastructure/health', + href: '/platform/health', + icon: FileHeartIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-health-runs', + label: 'Platform Admin — Runs Health', + href: '/platform/health/runs', + icon: FileHeartIcon, + requiresPlatformAdmin: true, + }, + { + id: 'page-platform-health-queue', + label: 'Platform Admin — Queue Health', + href: '/platform/health/queue', icon: FileHeartIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-triggers', label: 'Platform Admin — Triggers', - href: '/platform/infrastructure/triggers', + href: '/platform/triggers', icon: MousePointerClickIcon, requiresPlatformAdmin: true, }, { id: 'page-platform-event-streaming', label: 'Platform Admin — Event Streaming', - href: '/platform/infrastructure/event-destinations', + href: '/platform/audit-log/streaming', icon: WebhookIcon, requiresPlatformAdmin: true, }, diff --git a/packages/web/src/app/components/platform-layout.tsx b/packages/web/src/app/components/platform-layout.tsx index 5857e1a4e7eb..ebcbb5d9209d 100644 --- a/packages/web/src/app/components/platform-layout.tsx +++ b/packages/web/src/app/components/platform-layout.tsx @@ -19,10 +19,10 @@ export function PlatformLayout({ children }: { children: React.ReactNode }) { {showPlatformAdminDashboard ? ( - + -
+
); } + +const PLATFORM_SIDEBAR_STYLE: React.CSSProperties & { + '--sidebar-width': string; +} = { + '--sidebar-width': '16rem', +}; diff --git a/packages/web/src/app/components/sidebar/ap-sidebar-item-utils.ts b/packages/web/src/app/components/sidebar/ap-sidebar-item-utils.ts new file mode 100644 index 000000000000..7ce7ea9c6618 --- /dev/null +++ b/packages/web/src/app/components/sidebar/ap-sidebar-item-utils.ts @@ -0,0 +1,10 @@ +function sectionSearch(search: string): string { + const params = new URLSearchParams(search); + return new URLSearchParams( + [...params].filter(([key]) => SECTION_SEARCH_KEYS.includes(key)), + ).toString(); +} + +const SECTION_SEARCH_KEYS = ['month']; + +export const sidebarItemUtils = { sectionSearch }; diff --git a/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx b/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx index bd67b348e8b8..4d8cf31c6688 100644 --- a/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx +++ b/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx @@ -1,42 +1,70 @@ -import { LockKeyhole } from 'lucide-react'; +import { ApEdition, ApFlagId, TelemetryEventName } from '@activepieces/shared'; +import { t } from 'i18next'; +import { ChevronDown, ChevronRight, Crown } from 'lucide-react'; import React, { ComponentType, useEffect, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { Link, matchPath, useLocation } from 'react-router-dom'; -import { Dot } from '@/components/custom/dot'; +import { useTelemetry } from '@/components/providers/telemetry-provider'; import { + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, useSidebar, } from '@/components/ui/sidebar-shadcn'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { FeatureTier, TIER_LABELS } from '@/features/billing'; +import { flagsHooks } from '@/hooks/flags-hooks'; import { cn } from '@/lib/utils'; -export type SidebarItemType = { - to: string; - label: string; - type: 'link'; - icon?: ComponentType<{ className?: string }>; - notification?: boolean; - locked?: boolean; - newWindow?: boolean; - isActive?: (pathname: string) => boolean; - isSubItem?: boolean; - show?: boolean; - hasPermission?: boolean; - onClick?: () => void; - badge?: string; - iconClassName?: string; - highlight?: boolean; -}; +import { sidebarItemUtils } from './ap-sidebar-item-utils'; export const ApSidebarItem = (item: SidebarItemType) => { const location = useLocation(); - const navigate = useNavigate(); const { state } = useSidebar(); + const { capture } = useTelemetry(); const iconRef = useRef(null); const [isHovered, setIsHovered] = useState(false); - const isLinkActive = - location.pathname.startsWith(item.to) || item.isActive?.(location.pathname); + const pathname = location.pathname; + const isLinkActive = isRouteActive({ pathname, to: item.to }); const isCollapsed = state === 'collapsed'; + const subItems = item.subItems ?? []; + const hasSubItems = subItems.length > 0; + const [isExpanded, setIsExpanded] = useState(isLinkActive); + const showSubItems = hasSubItems && isExpanded && !isCollapsed; + const isSubItemLocked = (subItem: SidebarSubItemType) => + Boolean(item.locked) || Boolean(subItem.locked); + const isCrowned = hasSubItems + ? subItems.every(isSubItemLocked) + : Boolean(item.locked); + const isRowHighlighted = !hasSubItems && isLinkActive; + const parentTier = item.tier ?? subItems.find(isSubItemLocked)?.tier; + + const captureLockedClick = ({ path, tier }: LockedClick) => + capture({ + name: TelemetryEventName.ADMIN_NAV_LOCKED_CLICKED, + payload: { path, tier }, + }); + + const keepSearchWithinSection = (to: string) => { + if (!hasSubItems || !isLinkActive) { + return to; + } + const shared = sidebarItemUtils.sectionSearch(location.search); + return shared === '' ? to : `${to}?${shared}`; + }; + + useEffect(() => { + if (isLinkActive) { + setIsExpanded(true); + } + }, [isLinkActive]); useEffect(() => { if (isHovered) { @@ -48,54 +76,198 @@ export const ApSidebarItem = (item: SidebarItemType) => { const button = ( { - item.onClick?.(); - navigate(item.to); - }} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + asChild + className={cn('h-8 [&_svg]:block [&_svg]:size-5', { + 'bg-sidebar-accent hover:bg-sidebar-accent!': isRowHighlighted, + 'pr-8': hasSubItems && !isCollapsed, + })} > - {item.icon && renderIcon(item.icon, iconRef, item.iconClassName)} - {!isCollapsed && ( - - {item.label} - - )} - {!isCollapsed && item.badge && ( - - {item.badge} - + { + if (hasSubItems) { + setIsExpanded(true); + } + if ( + isCrowned && + !isRouteActive({ pathname, to: item.to, end: true }) + ) { + captureLockedClick({ path: item.to, tier: parentTier }); + } + }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {item.icon && renderIcon({ Icon: item.icon, ref: iconRef })} + {!isCollapsed && ( + + + {item.label} + + {isCrowned && } + + )} + + + ); + + return ( + + {isCrowned && !isCollapsed ? ( + {button} + ) : ( + button )} - {!isCollapsed && item.locked && !item.badge && ( - + {!isCollapsed && hasSubItems && ( + setIsExpanded((expanded) => !expanded)} + > + {isExpanded ? ( + + ) : ( + + )} + )} - {item.notification && !item.locked && ( - + {showSubItems && ( + + {subItems.map((subItem) => { + const shut = isSubItemLocked(subItem); + const subItemActive = isRouteActive({ + pathname, + to: subItem.to, + end: subItem.end, + }); + const subButton = ( + + + captureLockedClick({ + path: subItem.to, + tier: subItem.tier, + }) + : undefined + } + > + + {subItem.label} + {shut && !isCrowned && } + + + + ); + return ( + + {shut && !isCrowned ? ( + {subButton} + ) : ( + subButton + )} + + ); + })} + )} - + ); - - return {button}; }; -function renderIcon( - Icon: ComponentType<{ className?: string }>, - ref: React.RefObject, - iconClassName?: string, -) { +function LockedTooltip({ tier, children }: LockedTooltipProps) { + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const namedTier = edition === ApEdition.COMMUNITY ? undefined : tier; + + return ( + + {children} + + {namedTier === undefined + ? t('Not included in your plan') + : t('Included in the {tier} plan', { tier: TIER_LABELS[namedTier] })} + + + ); +} + +function CrownMark() { + return ( + <> + + {t('Requires a plan upgrade')} + + ); +} + +function isRouteActive({ + pathname, + to, + end = false, +}: { + pathname: string; + to: string; + end?: boolean; +}) { + return matchPath({ path: to, end }, pathname) !== null; +} + +function renderIcon({ + Icon, + ref, +}: { + Icon: ComponentType<{ className?: string }>; + ref: React.RefObject; +}) { return React.createElement(Icon, { - className: cn('size-4 pointer-events-none', iconClassName), + className: 'size-5 shrink-0 pointer-events-none', ref, } as { className: string }); } +export type SidebarSubItemType = { + to: string; + label: string; + end?: boolean; + locked?: boolean; + tier?: FeatureTier; +}; + +export type SidebarItemType = { + to: string; + label: string; + type: 'link'; + icon?: ComponentType<{ className?: string }>; + locked?: boolean; + tier?: FeatureTier; + subItems?: SidebarSubItemType[]; +}; + +type LockedTooltipProps = { + tier?: FeatureTier; + children: React.ReactElement; +}; + +type LockedClick = { + path: string; + tier?: FeatureTier; +}; + type AnimatedIconHandle = { startAnimation: () => void; stopAnimation: () => void; diff --git a/packages/web/src/app/components/sidebar/platform/index.tsx b/packages/web/src/app/components/sidebar/platform/index.tsx index 4d96b291867b..aad9d3f02a3f 100644 --- a/packages/web/src/app/components/sidebar/platform/index.tsx +++ b/packages/web/src/app/components/sidebar/platform/index.tsx @@ -1,14 +1,32 @@ import { ApEdition, ApFlagId } from '@activepieces/shared'; import { t } from 'i18next'; -import { Fragment, useRef } from 'react'; -import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { useRef } from 'react'; +import { Link } from 'react-router-dom'; -import { adminPagesUtils } from '@/app/routes/platform/admin-pages'; +import { McpSvg } from '@/assets/img/custom/mcp'; +import { ChartLineIcon } from '@/components/icons/chart-line'; import { ChevronLeftIcon, ChevronLeftIconHandle, } from '@/components/icons/chevron-left'; +import { FileHeartIcon } from '@/components/icons/file-heart'; +import { FileJson2Icon } from '@/components/icons/file-json2'; +import { FrameIcon } from '@/components/icons/frame'; +import { KeyRoundIcon } from '@/components/icons/key-round'; +import { LayoutGridIcon } from '@/components/icons/layout-grid'; +import { LogInIcon } from '@/components/icons/log-in'; +import { MousePointerClickIcon } from '@/components/icons/mouse-pointer-click'; +import { PuzzleIcon } from '@/components/icons/puzzle'; +import { ReceiptIcon } from '@/components/icons/receipt'; +import { ServerIcon } from '@/components/icons/server'; +import { SettingsIcon } from '@/components/icons/settings'; +import { Settings2Icon } from '@/components/icons/settings2'; +import { SparklesIcon } from '@/components/icons/sparkles'; +import { SquareDashedBottomCodeIcon } from '@/components/icons/square-dashed-bottom-code'; +import { UnplugIcon } from '@/components/icons/unplug'; +import { UsersIcon } from '@/components/icons/users'; import { buttonVariants } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Sidebar, SidebarContent, @@ -17,17 +35,18 @@ import { SidebarHeader, SidebarGroup, SidebarGroupContent, + SidebarGroupLabel, } from '@/components/ui/sidebar-shadcn'; +import { PLATFORM_FEATURES } from '@/features/billing'; import { useAuthorization } from '@/hooks/authorization-hooks'; import { flagsHooks } from '@/hooks/flags-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; import { determineDefaultRoute } from '@/lib/route-utils'; import { cn } from '@/lib/utils'; +import { ApSidebarItem, SidebarItemType } from '../ap-sidebar-item'; import { SidebarUser } from '../sidebar-user'; -import { PlatformNavItem, PlatformNavSubItem } from './platform-nav-item'; - export function PlatformSidebar() { const { platform } = platformHooks.useCurrentPlatform(); const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); @@ -38,16 +57,237 @@ export function PlatformSidebar() { }); const chevronRef = useRef(null); - const location = useLocation(); - const context = { plan: platform.plan, edition }; - const pages = adminPagesUtils.visibleNavPages(context); - const activeId = adminPagesUtils.activePageId(location.pathname); - const [searchParams] = useSearchParams(); - const requestedTabId = searchParams.get('tab'); + const groups: { label: string; items: PlatformNavItem[] }[] = [ + { + label: t('Platform'), + items: [ + { + to: '/platform/projects', + label: t('Projects'), + icon: LayoutGridIcon, + }, + { + to: '/platform/users', + label: t('Users'), + icon: UsersIcon, + subItems: [ + { to: '/platform/users', label: t('Members'), end: true }, + { + to: '/platform/users/roles', + label: t('Project Roles'), + locked: !platform.plan.projectRolesEnabled, + tier: PLATFORM_FEATURES.projectRoles.tier, + }, + ], + }, + { + to: '/platform/connections', + label: t('Connections'), + icon: UnplugIcon, + subItems: [ + { to: '/platform/connections', label: t('All'), end: true }, + { + to: '/platform/connections/global', + label: t('Global Connections'), + locked: !platform.plan.globalConnectionsEnabled, + tier: PLATFORM_FEATURES.globalConnections.tier, + }, + ], + }, + ], + }, + { + label: t('Catalogue'), + items: [ + { + to: '/platform/pieces', + label: t('Pieces'), + icon: PuzzleIcon, + subItems: [ + { to: '/platform/pieces', label: t('Pieces'), end: true }, + { + to: '/platform/pieces/piece-sets', + label: t('Piece Sets'), + locked: !platform.plan.managePiecesEnabled, + tier: PLATFORM_FEATURES.pieces.tier, + }, + ], + }, + { + to: '/platform/templates', + label: t('Templates'), + icon: LayoutGridIcon, + locked: !platform.plan.manageTemplatesEnabled, + tier: PLATFORM_FEATURES.templates.tier, + }, + { + to: '/platform/ai', + label: t('AI Center'), + icon: SparklesIcon, + subItems: + edition === ApEdition.COMMUNITY + ? undefined + : [ + { to: '/platform/ai', label: t('Providers'), end: true }, + { + to: '/platform/ai/capabilities', + label: t('Capabilities'), + }, + ], + }, + ], + }, + { + label: t('Security'), + items: [ + { + to: '/platform/sso', + label: t('Single Sign On'), + icon: LogInIcon, + locked: !platform.plan.ssoEnabled, + tier: PLATFORM_FEATURES.sso.tier, + }, + { + to: '/platform/secret-managers', + label: t('Secret Managers'), + icon: KeyRoundIcon, + locked: !platform.plan.secretManagersEnabled, + tier: PLATFORM_FEATURES.secretManagers.tier, + }, + { + to: '/platform/audit-log', + label: t('Audit Logs'), + icon: SquareDashedBottomCodeIcon, + subItems: [ + { + to: '/platform/audit-log', + label: t('Events'), + end: true, + locked: !platform.plan.auditLogEnabled, + tier: PLATFORM_FEATURES.auditLogs.tier, + }, + { + to: '/platform/audit-log/streaming', + label: t('Event Streaming'), + locked: !platform.plan.eventStreamingEnabled, + tier: PLATFORM_FEATURES.eventStreaming.tier, + }, + ], + }, + ], + }, + { + label: t('Developers'), + items: [ + { + to: '/platform/api-keys', + label: t('API Keys'), + icon: FileJson2Icon, + locked: !platform.plan.apiKeysEnabled, + tier: PLATFORM_FEATURES.apiKeys.tier, + }, + { + to: '/platform/embedding', + label: t('Embedding'), + icon: FrameIcon, + locked: !platform.plan.embeddingEnabled, + tier: PLATFORM_FEATURES.embedding.tier, + }, + { + to: '/platform/mcp', + label: t('MCP Server'), + icon: McpSvg, + subItems: [ + { to: '/platform/mcp', label: t('Connection'), end: true }, + { to: '/platform/mcp/tools', label: t('Tools') }, + { to: '/platform/mcp/activity', label: t('Activity') }, + ], + }, + ], + }, + { + label: t('Operations'), + items: [ + { + to: '/platform/workers', + label: t('Workers'), + icon: ServerIcon, + subItems: [ + { + to: '/platform/workers', + label: t('Health'), + end: true, + }, + { + to: '/platform/workers/groups', + label: t('Worker groups'), + locked: !platform.plan.workerGroupsEnabled, + }, + ], + }, + { + to: '/platform/health', + label: t('Health'), + icon: FileHeartIcon, + subItems: [ + { + to: '/platform/health', + label: t('System Health'), + end: true, + }, + { + to: '/platform/health/runs', + label: t('Runs Health'), + }, + { + to: '/platform/health/queue', + label: t('Queue Health'), + }, + ], + }, + { + to: '/platform/triggers', + label: t('Triggers'), + icon: MousePointerClickIcon, + }, + ], + }, + { + label: t('Account'), + items: [ + { + to: '/platform/general', + label: t('General'), + icon: SettingsIcon, + }, + { + to: '/platform/billing', + label: t('Billing & subscription'), + icon: ReceiptIcon, + locked: edition === ApEdition.COMMUNITY, + }, + { + to: '/platform/usage', + label: t('Usage'), + icon: ChartLineIcon, + locked: edition === ApEdition.COMMUNITY, + }, + ...(edition === ApEdition.CLOUD + ? [] + : [ + { + to: '/platform/configurations', + label: t('Configurations'), + icon: Settings2Icon, + }, + ]), + ], + }, + ]; return ( - + {t('Back to app')} -
- - - - - {pages.map((page) => { - const tabs = adminPagesUtils.navTabs({ page, context }); - const isActivePage = page.id === activeId; - const activeTabId = adminPagesUtils.activeTabId({ - page, - context, - requested: requestedTabId, - }); - return ( - - tab.id === activeTabId) - } - crowned={adminPagesUtils.isCrowned({ page, context })} - /> - {tabs.map((tab) => ( - - ))} - - ); - })} - - - - -
+ + + {groups.map((group, idx) => ( + 0 && 'mt-4', + )} + > + + {group.label} + + + + {group.items.map((item) => ( + + ))} + + + + ))} + + - +
); } + +type PlatformNavItem = Pick< + SidebarItemType, + 'to' | 'label' | 'icon' | 'locked' | 'tier' | 'subItems' +>; diff --git a/packages/web/src/app/components/sidebar/platform/platform-nav-item.tsx b/packages/web/src/app/components/sidebar/platform/platform-nav-item.tsx deleted file mode 100644 index 3956605081fd..000000000000 --- a/packages/web/src/app/components/sidebar/platform/platform-nav-item.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Crown } from 'lucide-react'; -import React, { ComponentType, useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { - SidebarMenuButton, - SidebarMenuItem, -} from '@/components/ui/sidebar-shadcn'; -import { cn } from '@/lib/utils'; - -export function PlatformNavItem({ - to, - label, - icon, - active, - crowned, -}: PlatformNavItemProps) { - const navigate = useNavigate(); - const iconRef = useRef(null); - const [isHovered, setIsHovered] = useState(false); - - useEffect(() => { - if (isHovered) { - iconRef.current?.startAnimation(); - } else { - iconRef.current?.stopAnimation(); - } - }, [isHovered]); - - return ( - - navigate(to)} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {icon !== undefined && - React.createElement(icon, { - className: 'size-4 pointer-events-none', - ref: iconRef, - })} - - {label} - - {crowned && } - - - ); -} - -export function PlatformNavSubItem({ - to, - label, - active, - crowned, -}: PlatformNavSubItemProps) { - const navigate = useNavigate(); - - return ( - -
-
- navigate(to)} - > - - {label} - - {crowned && } - -
- - ); -} - -export type AnimatedIconHandle = { - startAnimation: () => void; - stopAnimation: () => void; -}; - -export type AnimatedIconComponent = ComponentType<{ - className?: string; - ref?: React.Ref; -}>; - -export type PlatformNavItemProps = { - to: string; - label: string; - icon?: AnimatedIconComponent; - active: boolean; - crowned: boolean; -}; - -export type PlatformNavSubItemProps = { - to: string; - label: string; - active: boolean; - crowned: boolean; -}; diff --git a/packages/web/src/app/routes/chat-with-ai/components/chat-empty-state.tsx b/packages/web/src/app/routes/chat-with-ai/components/chat-empty-state.tsx index 306926545b61..25634d681b34 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/chat-empty-state.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/chat-empty-state.tsx @@ -109,7 +109,7 @@ export function SetupRequiredState() { )}

- diff --git a/packages/web/src/app/routes/mcp-server/pieces/pieces-tab.tsx b/packages/web/src/app/routes/mcp-server/pieces/pieces-tab.tsx index 85d41bbb5e0c..5f39df65e2b7 100644 --- a/packages/web/src/app/routes/mcp-server/pieces/pieces-tab.tsx +++ b/packages/web/src/app/routes/mcp-server/pieces/pieces-tab.tsx @@ -32,7 +32,7 @@ import { ProjectPicker } from './project-picker'; const RUN_ACTION_TOOL_NAME = 'ap_run_action'; const COLLAPSED_ROW_LIMIT = 6; const COLLAPSED_ROW_HEIGHT = 50; -const PIECE_SETS_LIST_ROUTE = '/platform/setup/pieces?tab=piece-sets'; +const PIECE_SETS_LIST_ROUTE = '/platform/pieces/piece-sets'; const SEARCH_DEBOUNCE_MS = 300; export function PiecesTab({ projectId, onSelectProject }: PiecesTabProps) { @@ -260,7 +260,7 @@ function PieceSetBanner({ projectId }: { projectId: string | null }) { diff --git a/packages/web/src/app/routes/platform-routes.tsx b/packages/web/src/app/routes/platform-routes.tsx index 1f1b611364f5..4e5a64ca2197 100644 --- a/packages/web/src/app/routes/platform-routes.tsx +++ b/packages/web/src/app/routes/platform-routes.tsx @@ -1,123 +1,557 @@ -import { ApEdition, ApFlagId } from '@activepieces/shared'; import React, { Suspense } from 'react'; -import { Navigate, useSearchParams } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; -import { FeatureSample } from '@/app/components/feature-sample'; import { PageTitle } from '@/app/components/page-title'; import { RouteLoadingBar } from '@/components/custom/route-loading-bar'; -import { flagsHooks } from '@/hooks/flags-hooks'; -import { platformHooks } from '@/hooks/platform-hooks'; +import { Error, Success } from '@/features/billing'; import { PlatformLayout } from '../components/platform-layout'; -import { - ADMIN_PAGES, - ADMIN_REDIRECTS, - AdminPage as AdminPageSpec, - adminPagesUtils, -} from './platform/admin-pages'; +import { LegacyPathRedirect } from './platform/legacy-path-redirect'; +import { LegacyTabRedirect } from './platform/legacy-tab-redirect'; +import { PlanFeatureSample } from './platform/plan-feature-sample'; + +const SettingsBilling = React.lazy(() => + import('./platform/billing').then((m) => ({ default: m.BillingPlanTab })), +); +const SettingsUsage = React.lazy(() => + import('./platform/billing').then((m) => ({ default: m.BillingUsageTab })), +); +const EventDestinationsPage = React.lazy( + () => import('./platform/infra/event-destinations'), +); +const SettingsHealthPage = React.lazy(() => import('./platform/infra/health')); +const PlatformConfigurationsPage = React.lazy(() => + import('./platform/infra/configurations').then((m) => ({ + default: m.ConfigurationsPage, + })), +); +const TriggerHealthPage = React.lazy(() => import('./platform/infra/triggers')); +const SettingsWorkersPage = React.lazy( + () => import('./platform/infra/workers'), +); +const ProjectsPage = React.lazy(() => import('./platform/projects')); +const ApiKeysPage = React.lazy(() => + import('./platform/security/api-keys').then((m) => ({ + default: m.ApiKeysPage, + })), +); +const AuditLogsPage = React.lazy( + () => import('./platform/security/audit-logs'), +); +const ProjectRolePage = React.lazy(() => + import('./platform/security/project-role').then((m) => ({ + default: m.ProjectRolePage, + })), +); +const SecretManagersPage = React.lazy( + () => import('./platform/security/secret-managers'), +); +const EmbedPage = React.lazy(() => + import('./platform/security/embed').then((m) => ({ + default: m.EmbedPage, + })), +); +const SSOPage = React.lazy(() => + import('./platform/security/sso').then((m) => ({ default: m.SSOPage })), +); +const AIProvidersPage = React.lazy(() => import('./platform/setup/ai')); +const PlatformMcpPage = React.lazy(() => import('./platform/setup/mcp')); +const GeneralPage = React.lazy(() => + import('./platform/setup/general').then((m) => ({ + default: m.GeneralPage, + })), +); +const GlobalConnectionsTable = React.lazy(() => + import('./platform/setup/connections').then((m) => ({ + default: m.GlobalConnectionsTable, + })), +); +const PiecesListPage = React.lazy(() => + import('./platform/setup/pieces').then((m) => ({ + default: m.PiecesListTab, + })), +); +const PieceSetsPage = React.lazy(() => + import('./platform/setup/pieces/piece-sets/piece-sets-tab').then((m) => ({ + default: m.PieceSetsTab, + })), +); +const PieceSetDetailsPage = React.lazy(() => + import('./platform/setup/pieces/piece-sets/piece-set-details-page').then( + (m) => ({ default: m.PieceSetDetailsPage }), + ), +); +const PlatformTemplatesPage = React.lazy(() => + import('./platform/setup/templates').then((m) => ({ + default: m.PlatformTemplatesPage, + })), +); +const UsersPage = React.lazy(() => import('./platform/users')); +const PlatformConnectionsPage = React.lazy( + () => import('./platform/connections'), +); function SuspenseWrapper({ children }: { children: React.ReactNode }) { return }>{children}; } -function AdminRoute({ page }: { page: AdminPageSpec }) { - const { platform } = platformHooks.useCurrentPlatform(); - const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); - const [searchParams] = useSearchParams(); - const context = { plan: platform.plan, edition }; - - const Page = page.component; - if (Page !== undefined) { - return ( - - - - - - ); - } - - const tabs = adminPagesUtils.visibleTabs({ page, context }); - const requested = searchParams.get('tab'); - const Overview = page.overview; - if (Overview !== undefined && requested === null) { - return ( - - - - - - ); - } - - const requestedTab = tabs.find((tab) => tab.id === requested); - if (requested !== null && requestedTab === undefined) { - return ; - } - const activeTab = requestedTab ?? tabs[0]; - if (activeTab === undefined) { - return null; - } - - const TabContent = activeTab.component; - return ( - - - - - - ); -} +const HEALTH_TAB_PATHS = { system: '', runs: 'runs', queue: 'queue' }; +const WORKERS_TAB_PATHS = { health: '', 'worker-groups': 'groups' }; +const AI_TAB_PATHS = { providers: '', capabilities: 'capabilities' }; +const PIECES_TAB_PATHS = { pieces: '', 'piece-sets': 'piece-sets' }; export const platformRoutes = [ - ...ADMIN_PAGES.map((page) => ({ - path: page.path, + { + path: '/platform', element: ( - - + + ), - })), - ...ADMIN_REDIRECTS.map(({ from, to, title, replace = true }) => ({ - path: from, - element: - title === undefined ? ( - - ) : ( - - - - - - ), - })), + }, + { + path: '/platform/projects', + element: ( + + + + + + + + ), + }, + { + path: '/platform/users', + element: ( + + + + + + + + ), + }, + { + path: '/platform/connections', + element: ( + + + + + + + + ), + }, + { + path: '/platform/ai', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/ai/capabilities', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/mcp', + element: ( + + + + + + + + ), + }, + { + path: '/platform/mcp/tools', + element: ( + + + + + + + + ), + }, + { + path: '/platform/mcp/activity', + element: ( + + + + + + + + ), + }, + { + path: '/platform/pieces', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/pieces/piece-sets', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/pieces/piece-sets/:id', + element: ( + + + + + + + + ), + }, + { + path: '/platform/connections/global', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/templates', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/general', + element: ( + + + + + + + + ), + }, + { + path: '/platform/billing', + element: ( + + + + + + + + ), + }, + { + path: '/platform/usage', + element: ( + + + + + + + + ), + }, + { + path: '/platform/billing/success', + element: ( + + + + + + ), + }, + { + path: '/platform/billing/error', + element: ( + + + + + + ), + }, + { + path: '/platform/api-keys', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/secret-managers', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/audit-log', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/embedding', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/sso', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/users/roles', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/workers', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/workers/groups', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/health', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/health/runs', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/health/queue', + element: ( + + + + + + + + + + ), + }, + { + path: '/platform/configurations', + element: ( + + + + + + + + ), + }, + { + path: '/platform/triggers', + element: ( + + + + + + + + ), + }, + { + path: '/platform/audit-log/streaming', + element: ( + + + + + + + + + + ), + }, + ...[ + '/platform/setup/*', + '/platform/security/*', + '/platform/infrastructure/*', + ].map((path) => ({ path, element: })), ]; diff --git a/packages/web/src/app/routes/platform/admin-pages.ts b/packages/web/src/app/routes/platform/admin-pages.ts deleted file mode 100644 index bfe653a070f7..000000000000 --- a/packages/web/src/app/routes/platform/admin-pages.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { ApEdition, PlatformWithoutSensitiveData } from '@activepieces/shared'; -import React, { ComponentType } from 'react'; - -import { FeatureTeaserProps } from '@/app/components/feature-teaser'; -import { FrameIcon } from '@/components/icons/frame'; -import { LayoutGridIcon } from '@/components/icons/layout-grid'; -import { PuzzleIcon } from '@/components/icons/puzzle'; -import { ReceiptIcon } from '@/components/icons/receipt'; -import { ServerIcon } from '@/components/icons/server'; -import { SettingsIcon } from '@/components/icons/settings'; -import { ShieldIcon } from '@/components/icons/shield'; -import { SparklesIcon } from '@/components/icons/sparkles'; -import { UnplugIcon } from '@/components/icons/unplug'; -import { UsersIcon } from '@/components/icons/users'; -import { Error as BillingError, Success } from '@/features/billing'; - -const UsersOverview = React.lazy(() => - import('./overview/users-overview').then((m) => ({ - default: m.UsersOverview, - })), -); -const ConnectionsOverview = React.lazy(() => - import('./overview/connections-overview').then((m) => ({ - default: m.ConnectionsOverview, - })), -); -const AiOverview = React.lazy(() => - import('./overview/ai-overview').then((m) => ({ default: m.AiOverview })), -); -const SecurityOverview = React.lazy(() => - import('./overview/security-overview').then((m) => ({ - default: m.SecurityOverview, - })), -); -const BillingOverview = React.lazy(() => - import('./overview/billing-overview').then((m) => ({ - default: m.BillingOverview, - })), -); -const InfrastructureOverview = React.lazy(() => - import('./overview/infrastructure-overview').then((m) => ({ - default: m.InfrastructureOverview, - })), -); -const ProjectsPage = React.lazy(() => import('./projects')); -const UsersPage = React.lazy(() => import('./users')); -const PlatformConnectionsPage = React.lazy(() => import('./connections')); -const GeneralPage = React.lazy(() => - import('./setup/general').then((m) => ({ default: m.GeneralPage })), -); -const ProvidersTab = React.lazy(() => - import('./setup/ai/providers-tab').then((m) => ({ default: m.ProvidersTab })), -); -const CapabilitiesTab = React.lazy(() => - import('./setup/ai/capabilities-tab').then((m) => ({ - default: m.CapabilitiesTab, - })), -); -const PlatformMcpPage = React.lazy(() => import('./setup/mcp')); -const GlobalConnectionsTable = React.lazy(() => - import('./setup/connections').then((m) => ({ - default: m.GlobalConnectionsTable, - })), -); -const PiecesListTab = React.lazy(() => - import('./setup/pieces').then((m) => ({ default: m.PiecesListTab })), -); -const PieceSetsTab = React.lazy(() => - import('./setup/pieces/piece-sets/piece-sets-tab').then((m) => ({ - default: m.PieceSetsTab, - })), -); -const PieceSetDetailsPage = React.lazy(() => - import('./setup/pieces/piece-sets/piece-set-details-page').then((m) => ({ - default: m.PieceSetDetailsPage, - })), -); -const PlatformTemplatesPage = React.lazy(() => - import('./setup/templates').then((m) => ({ - default: m.PlatformTemplatesPage, - })), -); -const BillingPlanTab = React.lazy(() => - import('./billing').then((m) => ({ default: m.BillingPlanTab })), -); -const BillingUsageTab = React.lazy(() => - import('./billing').then((m) => ({ default: m.BillingUsageTab })), -); -const SSOPage = React.lazy(() => - import('./security/sso').then((m) => ({ default: m.SSOPage })), -); -const ProjectRolePage = React.lazy(() => - import('./security/project-role').then((m) => ({ - default: m.ProjectRolePage, - })), -); -const ApiKeysPage = React.lazy(() => - import('./security/api-keys').then((m) => ({ default: m.ApiKeysPage })), -); -const SecretManagersPage = React.lazy( - () => import('./security/secret-managers'), -); -const AuditLogsPage = React.lazy(() => import('./security/audit-logs')); -const EmbedPage = React.lazy(() => - import('./security/embed').then((m) => ({ default: m.EmbedPage })), -); -const SettingsWorkersPage = React.lazy(() => import('./infra/workers')); -const SettingsHealthPage = React.lazy(() => import('./infra/health')); -const ConfigurationsPage = React.lazy(() => - import('./infra/configurations').then((m) => ({ - default: m.ConfigurationsPage, - })), -); -const TriggerHealthPage = React.lazy(() => import('./infra/triggers')); -const EventDestinationsPage = React.lazy( - () => import('./infra/event-destinations'), -); - -function isCommunity({ edition }: AdminPageContext) { - return edition === ApEdition.COMMUNITY; -} - -function hasNav(page: AdminPage): page is AdminNavPage { - return page.nav !== undefined; -} - -function visibleNavPages(context: AdminPageContext) { - return ADMIN_PAGES.filter(hasNav).filter( - (page) => page.nav.isHidden?.(context) !== true, - ); -} - -function visibleTabs({ page, context }: PageInContext) { - return (page.tabs ?? []).filter((tab) => tab.isHidden?.(context) !== true); -} - -function navTabs({ page, context }: PageInContext) { - return visibleTabs({ page, context }).filter((tab) => tab.hideInNav !== true); -} - -function activeTabId({ - page, - context, - requested, -}: PageInContext & { requested: string | null }) { - if (requested !== null) { - return requested; - } - if (page.overview !== undefined) { - return null; - } - return visibleTabs({ page, context })[0]?.id ?? null; -} - -function isCrowned({ - page, - context, -}: { - page: AdminNavPage; - context: AdminPageContext; -}) { - if (page.nav.isLocked?.(context) === true) { - return true; - } - const tabs = visibleTabs({ page, context }); - return ( - tabs.length > 0 && tabs.every((tab) => tab.isLocked?.(context) === true) - ); -} - -function activePageId(pathname: string) { - const matches = ADMIN_PAGES.filter( - (page) => pathname === page.path || pathname.startsWith(page.path + '/'), - ); - const best = matches.reduce( - (longest, page) => - longest === undefined || page.path.length > longest.path.length - ? page - : longest, - undefined, - ); - return best?.id; -} - -export const adminPagesUtils = { - visibleNavPages, - visibleTabs, - navTabs, - activeTabId, - isCrowned, - activePageId, -}; - -export const ADMIN_PAGES: AdminPage[] = [ - { - id: 'general', - path: '/platform/setup/general', - title: 'General', - component: GeneralPage, - nav: { label: 'General', icon: SettingsIcon }, - }, - { - id: 'projects', - path: '/platform/projects', - title: 'Projects', - component: ProjectsPage, - nav: { - label: 'Projects', - icon: LayoutGridIcon, - }, - }, - { - id: 'users', - path: '/platform/users', - title: 'Users & access', - overview: UsersOverview, - nav: { label: 'Users & access', icon: UsersIcon }, - tabs: [ - { id: 'members', label: 'Members', component: UsersPage }, - { - id: 'sso', - label: 'Single sign on', - component: SSOPage, - isLocked: ({ plan }) => !plan.ssoEnabled, - sample: true, - teaser: { - featureKey: 'SSO', - title: 'Enable Single Sign On', - description: - 'Let your users sign in with your current SSO provider or give them self serve sign up access', - tier: 'team', - bullets: [ - 'Works with SAML and OIDC providers', - 'Enforce SSO for everyone on the platform', - 'Control who can self-serve sign up', - ], - }, - }, - { - id: 'roles', - label: 'Project roles', - component: ProjectRolePage, - isLocked: ({ plan }) => !plan.projectRolesEnabled, - sample: true, - teaser: { - featureKey: 'CUSTOM_ROLES', - title: 'Project Role Management', - description: - 'Define custom roles and permissions to control what your team members can access and modify', - tier: 'team', - bullets: [ - 'Scope access per project, not per platform', - 'Keep production flows safe from accidental edits', - 'Assign roles when you invite someone', - ], - }, - }, - ], - }, - { - id: 'connections', - path: '/platform/connections', - title: 'Connections', - overview: ConnectionsOverview, - nav: { label: 'Connections', icon: UnplugIcon }, - tabs: [ - { - id: 'project', - label: 'Project connections', - component: PlatformConnectionsPage, - }, - { - id: 'global', - label: 'Global connections', - component: GlobalConnectionsTable, - isLocked: ({ plan }) => !plan.globalConnectionsEnabled, - sample: true, - teaser: { - featureKey: 'GLOBAL_CONNECTIONS', - title: 'Enable Global Connections', - description: 'Manage platform-wide connections to external systems.', - tier: 'team', - bullets: [ - 'Create once, use in any project', - 'Rotate credentials in a single place', - 'Choose which projects can use each connection', - ], - }, - }, - ], - }, - { - id: 'ai', - path: '/platform/setup/ai', - title: 'AI Center & MCP', - overview: AiOverview, - nav: { label: 'AI Center & MCP', icon: SparklesIcon }, - tabs: [ - { id: 'providers', label: 'Providers', component: ProvidersTab }, - { - id: 'capabilities', - label: 'Capabilities', - component: CapabilitiesTab, - isHidden: ({ edition }) => edition === ApEdition.COMMUNITY, - }, - { - id: 'mcp', - label: 'MCP Server', - component: PlatformMcpPage, - }, - ], - }, - { - id: 'pieces', - path: '/platform/setup/pieces', - title: 'Pieces', - nav: { - label: 'Pieces', - icon: PuzzleIcon, - }, - tabs: [ - { - id: 'pieces', - label: 'Pieces', - component: PiecesListTab, - hideInNav: true, - }, - { - id: 'piece-sets', - label: 'Piece Sets', - component: PieceSetsTab, - isLocked: ({ plan }) => !plan.managePiecesEnabled, - }, - ], - }, - { - id: 'templates', - sample: true, - teaser: { - featureKey: 'TEMPLATES', - title: 'Unlock Templates', - description: - 'Convert the most common automations into reusable templates 1 click away from your users', - tier: 'enterprise', - }, - path: '/platform/setup/templates', - title: 'Templates', - component: PlatformTemplatesPage, - nav: { - label: 'Templates', - icon: LayoutGridIcon, - isLocked: ({ plan }) => !plan.manageTemplatesEnabled, - }, - }, - { - id: 'embed', - sample: true, - teaser: { - featureKey: 'SIGNING_KEYS', - title: 'Unlock Embedding Through JS SDK', - description: 'Enable signing keys to access embedding functionalities.', - tier: 'enterprise', - }, - path: '/platform/security/embed', - title: 'Embedding', - component: EmbedPage, - nav: { - label: 'Embedding', - icon: FrameIcon, - isLocked: ({ plan }) => !plan.embeddingEnabled, - }, - }, - { - id: 'security', - path: '/platform/security', - title: 'Security', - overview: SecurityOverview, - nav: { - label: 'Security', - icon: ShieldIcon, - }, - tabs: [ - { - id: 'api-keys', - label: 'API keys', - component: ApiKeysPage, - isLocked: ({ plan }) => !plan.apiKeysEnabled, - sample: true, - teaser: { - featureKey: 'API', - title: 'Enable API Keys', - description: - 'Create and manage API keys to access Activepieces APIs.', - tier: 'team', - bullets: [ - 'Drive projects, flows and connections from your own tooling', - 'Scope a key to the platform, not to a person', - 'Revoke a key without touching anyone\u2019s login', - ], - }, - }, - { - id: 'secrets', - label: 'Secret managers', - component: SecretManagersPage, - isLocked: ({ plan }) => !plan.secretManagersEnabled, - sample: true, - teaser: { - featureKey: 'SECRET_MANAGERS', - title: 'Enable Secret Managers', - description: 'Manage your secrets from a single and secure place', - tier: 'enterprise', - bullets: [ - 'AWS, Azure, GCP and HashiCorp Vault', - 'Secrets never leave your infrastructure', - 'One secure place to rotate everything', - ], - }, - }, - { - id: 'audit', - label: 'Audit logs', - component: AuditLogsPage, - isLocked: ({ plan }) => !plan.auditLogEnabled, - sample: true, - teaser: { - featureKey: 'AUDIT_LOGS', - title: 'Unlock Audit Logs', - description: - 'Comply with internal and external security policies by tracking activities done within your account', - tier: 'enterprise', - bullets: [ - 'Every user and system action, recorded', - 'Filter by user, project and event type', - 'Export for compliance reviews', - ], - }, - }, - { - id: 'events', - label: 'Event streaming', - component: EventDestinationsPage, - isLocked: ({ plan }) => !plan.eventStreamingEnabled, - sample: true, - teaser: { - featureKey: 'EVENT_DESTINATIONS', - title: 'Unlock Event Streaming', - description: - 'Forward every audit event we emit to a webhook, then handle it in a flow.', - tier: 'enterprise', - bullets: [ - 'Stream events to any endpoint', - 'Wire alerts into Slack, PagerDuty or email', - 'Build your own monitoring on top', - ], - }, - }, - ], - }, - { - id: 'billing', - sample: true, - teaser: { - featureKey: 'BILLING', - showContactSales: false, - title: 'Billing & usage', - description: - 'Plans, credits and usage tracking are part of the Enterprise and Cloud editions.', - }, - path: '/platform/setup/billing', - title: 'Billing & usage', - overview: BillingOverview, - nav: { - label: 'Billing & usage', - icon: ReceiptIcon, - isLocked: isCommunity, - }, - tabs: [ - { id: 'plan', label: 'Plan', component: BillingPlanTab }, - { id: 'usage', label: 'Usage', component: BillingUsageTab }, - ], - }, - { - id: 'infrastructure', - path: '/platform/infrastructure', - title: 'Infrastructure', - overview: InfrastructureOverview, - nav: { label: 'Infrastructure', icon: ServerIcon }, - tabs: [ - { id: 'health', label: 'Health', component: SettingsHealthPage }, - { id: 'workers', label: 'Workers', component: SettingsWorkersPage }, - { id: 'triggers', label: 'Triggers', component: TriggerHealthPage }, - { - id: 'configurations', - label: 'Configurations', - component: ConfigurationsPage, - isHidden: ({ edition }) => edition === ApEdition.CLOUD, - }, - ], - }, - { - id: 'piece-set-details', - path: '/platform/setup/pieces/piece-sets/:id', - title: 'Piece Set', - component: PieceSetDetailsPage, - }, - { - id: 'billing-success', - path: '/platform/setup/billing/success', - title: 'Billing', - component: Success, - }, - { - id: 'billing-error', - path: '/platform/setup/billing/error', - title: 'Billing', - component: BillingError, - }, -]; - -export const ADMIN_REDIRECTS: AdminRedirect[] = [ - { - from: '/platform', - to: '/platform/projects', - title: 'Platform', - replace: false, - }, - { - from: '/platform/setup', - to: '/platform/setup/ai', - title: 'Platform Setup', - }, - { - from: '/platform/setup/ai-capabilities', - to: '/platform/setup/ai?tab=capabilities', - }, - { - from: '/platform/setup/usage', - to: '/platform/setup/billing?tab=usage', - }, - { - from: '/platform/setup/branding', - to: '/platform/setup/general', - }, - { from: '/platform/setup/mcp', to: '/platform/setup/ai?tab=mcp' }, - { - from: '/platform/security/api-keys', - to: '/platform/security?tab=api-keys', - }, - { from: '/platform/security/sso', to: '/platform/users?tab=sso' }, - { from: '/platform/security/project-roles', to: '/platform/users?tab=roles' }, - { - from: '/platform/setup/connections', - to: '/platform/connections?tab=global', - }, - { - from: '/platform/security/secret-managers', - to: '/platform/security?tab=secrets', - }, - { from: '/platform/security/audit-logs', to: '/platform/security?tab=audit' }, - { - from: '/platform/infrastructure/event-destinations', - to: '/platform/security?tab=events', - }, - { - from: '/platform/infrastructure/workers', - to: '/platform/infrastructure?tab=workers', - }, - { - from: '/platform/infrastructure/health', - to: '/platform/infrastructure?tab=health', - }, - { - from: '/platform/infrastructure/triggers', - to: '/platform/infrastructure?tab=triggers', - }, - { - from: '/platform/infrastructure/configurations', - to: '/platform/infrastructure?tab=configurations', - }, -]; - -type PageInContext = { - page: AdminPage; - context: AdminPageContext; -}; - -export type AdminPageContext = { - plan: PlatformWithoutSensitiveData['plan']; - edition: ApEdition | null; -}; - -export type AdminPageNav = { - label: string; - icon?: ComponentType<{ className?: string }>; - isLocked?: (context: AdminPageContext) => boolean; - isHidden?: (context: AdminPageContext) => boolean; -}; - -export type AdminPageTabSpec = { - id: string; - label: string; - component: ComponentType; - isLocked?: (context: AdminPageContext) => boolean; - isHidden?: (context: AdminPageContext) => boolean; - hideInNav?: boolean; - teaser?: FeatureTeaserProps; - sample?: boolean; -}; - -export type AdminPage = { - id: string; - path: string; - title: string; - component?: ComponentType; - overview?: ComponentType; - tabs?: AdminPageTabSpec[]; - nav?: AdminPageNav; - sample?: boolean; - teaser?: FeatureTeaserProps; -}; - -export const VIEW_QUERY_PARAM = 'view'; - -export type AdminNavPage = AdminPage & { nav: AdminPageNav }; - -export type AdminRedirect = { - from: string; - to: string; - title?: string; - replace?: boolean; -}; diff --git a/packages/web/src/app/routes/platform/infra/configurations/index.tsx b/packages/web/src/app/routes/platform/infra/configurations/index.tsx index 1679d0595596..b4f5af350665 100644 --- a/packages/web/src/app/routes/platform/infra/configurations/index.tsx +++ b/packages/web/src/app/routes/platform/infra/configurations/index.tsx @@ -31,7 +31,7 @@ export const ConfigurationsPage = () => { }); if (edition === ApEdition.CLOUD) { - return ; + return ; } if (isLoading || isNil(configuration)) { diff --git a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts index 92fce54d31da..6de0d3955043 100644 --- a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts +++ b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts @@ -129,6 +129,10 @@ const buildEventLabels = (): Record => ({ group: 'billing', label: t('Contacted sales'), }, + [TelemetryEventName.ADMIN_NAV_LOCKED_CLICKED]: { + group: 'billing', + label: t('Opened a paid admin page'), + }, }); const buildGroups = (): TrackedEventGroup[] => { diff --git a/packages/web/src/app/routes/platform/infra/health/index.tsx b/packages/web/src/app/routes/platform/infra/health/index.tsx index a43965bbfd95..fdbda533f109 100644 --- a/packages/web/src/app/routes/platform/infra/health/index.tsx +++ b/packages/web/src/app/routes/platform/infra/health/index.tsx @@ -1,11 +1,10 @@ import dayjs from 'dayjs'; import { t } from 'i18next'; -import { Activity, Calendar, HeartPulse, LineChart } from 'lucide-react'; +import { Calendar } from 'lucide-react'; import React from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { DashboardPageHeader } from '@/app/components/dashboard-page-header'; -import { VIEW_QUERY_PARAM } from '@/app/routes/platform/admin-pages'; import { Select, SelectContent, @@ -13,15 +12,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { QueueTab } from './components/queue-tab'; import { RunsTab } from './components/runs-tab'; import { SystemHealthTab } from './components/system-health-tab'; import { healthMetricsQueries } from './lib/health-metrics-hooks'; -type MonthOption = { value: string; label: string }; - function buildMonthOptions(): MonthOption[] { const now = dayjs(); return Array.from({ length: 6 }, (_unused, index) => { @@ -33,10 +29,12 @@ function buildMonthOptions(): MonthOption[] { }); } -export default function SettingsHealthPage() { +export default function SettingsHealthPage({ + section, +}: SettingsHealthPageProps) { const monthOptions = React.useMemo(buildMonthOptions, []); const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = parseTabValue(searchParams.get(VIEW_QUERY_PARAM)); + const navigate = useNavigate(); const selectedMonth = searchParams.get('month') || monthOptions[0].value; const range = React.useMemo(() => { @@ -52,19 +50,9 @@ export default function SettingsHealthPage() { isLoading: isReportLoading, isError: isReportError, refetch: refetchReport, - } = healthMetricsQueries.useRunMetrics(range, activeTab === 'runs'); + } = healthMetricsQueries.useRunMetrics(range, section === 'runs'); const { data: live, isLoading: isLiveLoading } = - healthMetricsQueries.useQueueMetrics(range, activeTab === 'queue'); - - const setTab = (tab: TabValue) => { - const newParams = new URLSearchParams(searchParams); - if (tab === 'system') { - newParams.delete(VIEW_QUERY_PARAM); - } else { - newParams.set(VIEW_QUERY_PARAM, tab); - } - setSearchParams(newParams, { replace: true }); - }; + healthMetricsQueries.useQueueMetrics(range, section === 'queue'); const handleMonthChange = (month: string) => { const newParams = new URLSearchParams(searchParams); @@ -78,7 +66,7 @@ export default function SettingsHealthPage() { title={t('Health')} description={t('Check the status of your platform and its components')} > - {(activeTab === 'runs' || activeTab === 'queue') && ( + {(section === 'runs' || section === 'queue') && (