diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ecf7d0a776de..700e21a4d228 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,4 +15,6 @@ # The leading slash is required — unanchored patterns would also match the # per-piece lockfiles under packages/pieces/. /bun.lock +/docs/ +/tsconfig.base.json /brain/ diff --git a/.github/workflows/continuous-delivery-canary.yml b/.github/workflows/continuous-delivery-canary.yml index 7e5c8ae197a1..6deea1fe854e 100644 --- a/.github/workflows/continuous-delivery-canary.yml +++ b/.github/workflows/continuous-delivery-canary.yml @@ -3,11 +3,23 @@ name: Continuous Delivery — Canary on: workflow_dispatch: workflow_call: + inputs: + image_tag: + description: 'Deploy this already-built tag instead of building a new image' + type: string + required: false + default: '' + skip_migration_check: + description: 'Deploy even when a pending migration is marked breaking' + type: boolean + required: false + default: false schedule: - cron: '0 9 * * *' # Daily 9 AM UTC — scheduled runs always use the default branch jobs: build-image: + if: ${{ !inputs.image_tag }} runs-on: ubuntu-24.04 permissions: contents: read @@ -54,6 +66,10 @@ jobs: check-migrations: needs: build-image + if: | + always() && + needs.build-image.result != 'failure' && + inputs.skip_migration_check != true runs-on: ubuntu-latest outputs: has_breaking: ${{ steps.check.outputs.has_breaking }} @@ -114,10 +130,18 @@ jobs: deploy-canary: needs: [build-image, check-migrations] + if: | + always() && + needs.build-image.result != 'failure' && + needs.check-migrations.result != 'failure' runs-on: ubuntu-latest environment: name: canary steps: + - name: Resolve image tag + id: image + run: echo "image_tag=${{ inputs.image_tag || needs.build-image.outputs.image_tag }}" >> $GITHUB_OUTPUT + - name: Configure SSH run: | mkdir -p ~/.ssh/ @@ -137,9 +161,9 @@ jobs: - name: Deploy canary app run: | - ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ needs.build-image.outputs.image_tag }} --config-file=config/app-canary.yml --skip-push; exit"' + ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ steps.image.outputs.image_tag }} --config-file=config/app-canary.yml --skip-push; exit"' - name: Deploy canary workers run: | - ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ needs.build-image.outputs.image_tag }} --config-file=config/worker-canary.yml --skip-push; exit"' + ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ steps.image.outputs.image_tag }} --config-file=config/worker-canary.yml --skip-push; exit"' diff --git a/.github/workflows/continuous-delivery-cloud.yml b/.github/workflows/continuous-delivery-cloud.yml index 9f6e1b1bb87e..e106d73b5af8 100644 --- a/.github/workflows/continuous-delivery-cloud.yml +++ b/.github/workflows/continuous-delivery-cloud.yml @@ -73,22 +73,23 @@ jobs: tags: ghcr.io/activepieces/activepieces-cloud:${{ steps.set-tag.outputs.image_tag }} deploy-canary: - needs: [build-image] + needs: [guard, build-image] if: | always() && - github.event_name != 'workflow_dispatch' && - needs.build-image.result == 'skipped' + needs.guard.result != 'failure' && + (needs.build-image.result == 'success' || needs.build-image.result == 'skipped') uses: ./.github/workflows/continuous-delivery-canary.yml + with: + image_tag: ${{ needs.build-image.outputs.image_tag }} + skip_migration_check: ${{ github.event_name == 'workflow_dispatch' }} secrets: inherit promote-to-production: needs: [build-image, deploy-canary] if: | always() && - ( - (needs.build-image.result == 'success' && github.event_name == 'workflow_dispatch') || - (needs.deploy-canary.result == 'success') - ) + needs.deploy-canary.result == 'success' && + (needs.build-image.result == 'success' || needs.build-image.result == 'skipped') runs-on: ubuntu-latest permissions: contents: write @@ -125,16 +126,6 @@ jobs: echo "image_tag=release-candidate" >> $GITHUB_OUTPUT fi - - name: Deploy App to canary - if: github.event_name == 'workflow_dispatch' - run: | - ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ steps.image.outputs.image_tag }} --config-file=config/app-canary.yml --skip-push; exit"' - - - name: Deploy Workers to canary - if: github.event_name == 'workflow_dispatch' - run: | - ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ steps.image.outputs.image_tag }} --config-file=config/worker-canary.yml --skip-push; exit"' - - name: Deploy App to production run: | ssh ops -t -t 'bash -ic "cd mrsk/prod && kamal deploy --version ${{ steps.image.outputs.image_tag }} --config-file=config/app.yml --skip-push; exit"' diff --git a/brain/knowledge/engineering/architecture-spine.md b/brain/knowledge/engineering/architecture-spine.md index 28d65f8411d1..7cac79f8c819 100644 --- a/brain/knowledge/engineering/architecture-spine.md +++ b/brain/knowledge/engineering/architecture-spine.md @@ -37,6 +37,14 @@ Activepieces: open-source AI-first workflow automation platform (self-hosted or **Don't `.max()` a business limit on a request body — cap server-side.** A `.max()` on a request-body field rejects the *whole* request with a 400 the moment a user crosses it, so a user editing a list that reaches 50 items loses their entire save. Reserve `.max()` for a true trust-boundary DoS guard (Fastify's global body limit already covers gross abuse) and let business limits just *apply*: accept the input and `slice(0, MAX)` in the service layer, so the write always succeeds with the limit quietly enforced. Surfaced 2026-07 on `POST /v1/chat/memory`, where the schema's `.max(50)`/`.max(280)` duplicated a `slice` the save helper already did — redundant *and* a data-loss bug. +**`unique()` from `core-utils` is O(n²) over `JSON.stringify` — never put it on a hot path.** It is `filter` + `findIndex` with a `JSON.stringify` on *both* sides of every comparison, so it blocks the event loop: 1k items → 42ms, 5k → 889ms, 10k → 3.6s, during which health checks, websockets and webhook dispatch all stall. It exists for deep-equality dedupe of objects; for primitives use `[...new Set(xs)]`. Found 2026-07 as the first statement of the bulk record delete the same PR was trying to speed up (GIT-1652). + +**`DeleteResult.affected` is `undefined` on PGlite — don't count rows with it.** TypeORM's `PostgresQueryRunner` only sets `affected` when the driver result carries `rowCount`; PGlite reports `affectedRows` instead and `typeorm-pglite` doesn't map it. So `result.affected ?? 0` is correct on `pg` and silently `0` on every PGlite deployment and test — the worst failure mode, since CI is green. Use `.returning('id')` and count the rows. + +**Migration timestamps are hand-picked, so two PRs in flight will collide.** `postgres-connection.ts` uses round numbers (`1815000000000`, `1816000000000`, …), not `Date.now()`, and TypeORM orders migrations by the 13-digit suffix of the class name. Two branches both taking "the next one" produce duplicate keys, and ordering — including `rollback-migrations.ts` — silently falls back to `getMigrations()` array order. Check `git ls-tree main packages/server/api/src/app/database/migration/postgres/` for the number before you commit, and re-check after any rebase. + +**`CREATE INDEX CONCURRENTLY IF NOT EXISTS` can record success over a permanently invalid index.** `CONCURRENTLY` requires `transaction = false`, so nothing rolls back an interrupted build — it leaves an `indisvalid = false` index. `IF NOT EXISTS` then matches on *name only*, so the retry skips it with a NOTICE and TypeORM marks the migration applied: the query the index was meant to fix stays slow, with a green migration log. An invalid index is not inert either — still maintained on every insert, still blocks HOT updates. Use `DROP INDEX CONCURRENTLY IF EXISTS` before the create, and assert `pg_index.indisvalid` after. The existing `1810`/`1815`/`1818` index migrations all carry this shape. + **TypeORM soft-delete (`@DeleteDateColumn`) is not canary/rollback-safe on a shared DB.** TypeORM only appends `WHERE "deleted" IS NULL` for code whose entity *declares* the column, so any two versions sharing one Postgres — every canary window (canary shares prod's DB), every rollback — means old code reads soft-deleted rows as live. During canary a row deleted by new code reappears live and editable on old-code requests; on rollback every soft-deleted row returns permanently. Partially unrecoverable, too: old code's delete is a hard `DELETE`, so it can destroy a resurrected row the new restore feature could otherwise bring back. Partial indexes (`WHERE deleted IS NULL`) also stop serving old queries → seq scans. Do it expand-contract: ship the column and make **all** read paths filter on it first, roll that out everywhere, and only then flip the write path to `softDelete()`. The additive column is fine — it's the read-semantics change that can't run split across versions, and the same applies to any migration where old code must interpret a column it doesn't know about. Seen in PR #14219 (feat: chat core). **Canary doesn't proxy websockets — only broadcasts reach canary users.** Canary is a worker group that *also* has its own app tier (`CANARY_APP_URL`, `IS_CANARY_APP`), sharing prod's Postgres and Redis. The prod app is the ingress and `canaryRoutingMiddleware` HTTP-proxies a platform whose `workerGroupId === 'canary'` to the canary app — but the middleware is registered inside the `/api` scope, so only `/api/*` is proxied (the SPA is served at root from the baked-in bundle) and it bails on upgrades: `if (request.headers.upgrade === 'websocket') return`. A canary platform therefore runs the **prod** frontend, and its websocket is terminated by **prod (old code)** while its HTTP and flow jobs run on canary. Across a version split, server→client broadcasts still work (socket.io's Redis adapter relays canary's `emit` name-agnostically), but inbound handlers — `LOCK_RESOURCE`/`UNLOCK_RESOURCE`, presence — run on old prod code and silently degrade. The fix, verified 2026-07: point canary-platform websockets at the already-live `canary.activepieces.com` by making the frontend socket URL a runtime value from an authenticated `/api` flag (that call *is* proxied, so canary answers `wss://canary.activepieces.com` and prod answers same-origin) and deferring socket creation until it resolves. Cross-origin is fine (`cors:{origin:'*'}`, token in `socket.auth`, not cookies), and it closes the inbound half of the seam too. kamal-proxy can't help — host/path routing only, no cookie/header routing — and `reply.from` is HTTP-only. Canary is the only worker group with a separate app tier; dedicated groups share the prod app, so their users' websockets already hit the right code. Workers are the mirror case: they carry `workerGroupId` in post-upgrade auth but use an explicit `socketUrl`, so canary workers must point at the canary app by config. diff --git a/brain/knowledge/engineering/cloud-deployment-paths.md b/brain/knowledge/engineering/cloud-deployment-paths.md index ca7801fec6ed..757a641c22e8 100644 --- a/brain/knowledge/engineering/cloud-deployment-paths.md +++ b/brain/knowledge/engineering/cloud-deployment-paths.md @@ -4,19 +4,21 @@ icon: 🚀 # Cloud Deployment Paths -How code reaches `cloud.activepieces.com`. Two workflows in `.github/workflows/`: `continuous-delivery-canary.yml` and `continuous-delivery-cloud.yml`. +How code reaches `cloud.activepieces.com`. Two workflows in `.github/workflows/`: `continuous-delivery-canary.yml` and `continuous-delivery-cloud.yml`. Both cloud paths run the same job graph — `guard` → `build-image` → `deploy-canary` → `promote-to-production` — and both reach prod only after canary deploys cleanly. ## The normal path -Cloud's `workflow_call`/scheduled run calls the canary workflow as a job, then promotes the `release-candidate` tag to prod. Canary builds its own `.canary` image; prod deploys the `release-candidate` tag, not that image. +Cloud's `workflow_call`/scheduled run skips `build-image`, so `deploy-canary` gets an empty `image_tag` and the canary workflow builds its own `.canary` image and runs `check-migrations`. Prod then deploys the `release-candidate` tag, not that image. ## The override path -`Continuous Delivery — Cloud` → **Run workflow** → `cloud-hotfix` builds a `.beta` image from the current branch and deploys it to canary **and** prod, in that order, from the `promote-to-production` job. It never invokes the canary workflow. A `guard` job refuses the hotfix if the scheduled promotion is under an hour away. +`Continuous Delivery — Cloud` → **Run workflow** → `cloud-hotfix` builds one `.beta` image from the current branch and hands its tag to the canary workflow via `image_tag`, so canary deploys the exact artifact prod is about to get instead of rebuilding it. It also passes `skip_migration_check: true`. A `guard` job refuses the hotfix if the scheduled promotion is under an hour away, and a rejected guard skips canary too. ## Staging (upstream of both) `continuous-delivery-stg.yml` builds every push to `main` and deploys it with **Kamal**, not Kubernetes: it SSHes to the devops box and runs `kamal deploy --config-file=config/{app,worker}.yml` from `/root/mrsk/stg`. App containers live on one host, workers on another; `kubectl`'s `stg` context on that box is dead and points at a node that no longer runs k3s — ignore it. Env vars for staging go in `config/app.yml` under `env.clear` (secrets are name-listed under `env.secret` and read from `.kamal/secrets`). The Thursday job retags whatever staging is running as `release-candidate`, which is what cloud promotes. ## Gotchas - **Kamal reads its config at deploy time, so editing `config/app.yml` during an in-flight CD run silently misses.** The build takes ~5 min and the deploy job reads the file when *it* starts; an edit that lands in between applies to neither the running containers nor the deploy. Worse, Kamal replaces containers one at a time, so a mid-deploy edit can leave app_1 and app_2 disagreeing about a flag — which reads downstream as a flaky feature, not a config race. Always re-run `kamal deploy --version --config-file=config/app.yml --skip-push` after editing, and verify with `docker inspect --format '{{range .Config.Env}}...'` on **every** container, not one. The command needs a TTY (`ssh -tt`); without it Kamal exits 0 having done nothing ("the input device is not a TTY"). -- **`check-migrations` gates canary *and* the scheduled cloud promotion.** The canary workflow fails when any pending migration carries `breaking = true` (rollback safety — see `tools/scripts/check-manifest-migrations.ts`). Because the scheduled cloud run calls that workflow, one breaking migration blocks both. The escape hatch is `cloud-hotfix`: it deploys canary and prod without touching the canary workflow, so it bypasses the gate by construction — there is no skip flag, and there is no canary-only override. +- **`check-migrations` gates canary *and* the cloud promotion that calls it.** The canary workflow fails when any pending migration carries `breaking = true` (rollback safety — see `tools/scripts/check-manifest-migrations.ts`), and the scheduled cloud run inherits that gate. `cloud-hotfix` passes `skip_migration_check: true` to bypass it: a deliberate cloud release ships the breaking migration to prod anyway, so blocking canary on it only leaves canary behind prod. There is still no canary-only override — dispatch the cloud workflow. +- **Skipping a `needs` job reads the same as a rejected one.** `build-image` is skipped both when the run is scheduled *and* when `guard` fails a hotfix, so `needs.build-image.result == 'skipped'` alone would deploy canary for a hotfix the guard just refused. `deploy-canary` therefore also needs `guard` and checks `needs.guard.result != 'failure'`. +- **`docker pull … error from registry: denied` on the ops host can be transient.** Seen 2026-08-12 pulling a `.beta` tag to the canary host; re-running the same job with no other change pulled and deployed fine. The tag existed in GHCR the whole time. Re-run once before suspecting the host's registry credential — GHCR does answer `GET /v2/` for a token that cannot pull, so login-succeeds/pull-denied is *consistent* with an expired credential, but it is not evidence of one. - **Never put a BuildKit cache mount on `/var/cache/apt` or `/var/lib/apt`.** The `node:*-bullseye-slim` base ships `/etc/apt/apt.conf.d/docker-clean`, which wipes downloaded `.deb`s and sets `Keep-Downloaded-Packages "false"` — so the mount caches nothing, but it does persist stale `apt` lists and `partial/` leftovers across Depot builds. When `bullseye-security` republishes and old `.deb`s rotate out, the next build dies on `Hash Sum mismatch` / `Unable to fetch some archives` → `exit code: 100`, which reads like a missing package but isn't. Removed from both Dockerfiles on 2026-08-12; a plain `apt-get update && apt-get install` is what works. Also: bullseye `main` has been frozen since Aug 2025 and Debian 11 LTS ends Aug 2026, so the base image needs a bookworm bump. - **`breaking = true` on a migration and the `⛓️‍💥 breaking-change` PR label are different axes.** The migration flag is about rollback safety and is what stops deploys; the label is about self-hoster upgrade impact and is enforced by `breaking-change-check.yml` on PRs. Neither implies the other. diff --git a/docs/build-pieces/building-pieces/create-action.mdx b/docs/build-pieces/building-pieces/create-action.mdx index 034cf7f0c554..435f851b40d5 100755 --- a/docs/build-pieces/building-pieces/create-action.mdx +++ b/docs/build-pieces/building-pieces/create-action.mdx @@ -54,7 +54,7 @@ export const getIcecreamFlavor = createAction({ method: HttpMethod.GET, url: 'https://cloud.activepieces.com/api/v1/webhooks/RGjv57ex3RAHOgs0YK6Ja/sync', headers: { - Authorization: context.auth, // Pass API key in headers + Authorization: context.auth.secret_text, // Pass API key in headers }, }); return res.body; @@ -73,7 +73,7 @@ The `run` function is the function that is called when the action is executed. I The `run` function utilizes the httpClient.sendRequest function to make a GET request, fetching a random ice cream flavor. It incorporates API key authentication in the request headers. Finally, it returns the response body. -You can describe how an action's output is presented in the builder — including readable labels for outputs keyed by opaque IDs or returned as arrays — by declaring an [Output Schema](/build-pieces/piece-reference/output-schema). +You can describe how an action's output is presented in the builder, including readable labels for outputs keyed by opaque IDs or returned as arrays, by declaring an [Output Schema](/build-pieces/piece-reference/output-schema). ## Expose The Definition diff --git a/docs/build-pieces/building-pieces/create-trigger.mdx b/docs/build-pieces/building-pieces/create-trigger.mdx index c737097215a3..92ea576c2591 100644 --- a/docs/build-pieces/building-pieces/create-trigger.mdx +++ b/docs/build-pieces/building-pieces/create-trigger.mdx @@ -61,7 +61,7 @@ const polling: Polling< method: HttpMethod.GET, url: 'https://cloud.activepieces.com/api/v1/webhooks/aHlEaNLc6vcF1nY2XJ2ed/sync', headers: { - authorization: auth, + authorization: auth.secret_text, }, }; const res = await httpClient.sendRequest(request); diff --git a/docs/build-pieces/building-pieces/development-setup.mdx b/docs/build-pieces/building-pieces/development-setup.mdx index c3a3cfd90142..f8911fa41a8c 100644 --- a/docs/build-pieces/building-pieces/development-setup.mdx +++ b/docs/build-pieces/building-pieces/development-setup.mdx @@ -5,7 +5,7 @@ icon: 'circle-2' ## Prerequisites -- Node.js v18+ +- Node.js v22.15+ or v24 - npm v9+ ## Instructions @@ -18,7 +18,7 @@ node tools/setup-dev.js 2. Start the environment -This command will start activepieces with sqlite3 and in memory queue. +This command will start activepieces with PGLite and an in-memory queue. ```bash npm start diff --git a/docs/build-pieces/building-pieces/piece-definition.mdx b/docs/build-pieces/building-pieces/piece-definition.mdx index 1295d588ba2c..0d22515c9516 100755 --- a/docs/build-pieces/building-pieces/piece-definition.mdx +++ b/docs/build-pieces/building-pieces/piece-definition.mdx @@ -42,8 +42,10 @@ import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; export const gelato = createPiece({ displayName: 'Gelato', + description: '', logoUrl: 'https://cdn.activepieces.com/pieces/gelato.png', auth: PieceAuth.None(), + minimumSupportedRelease: '0.36.1', authors: [], actions: [], triggers: [], diff --git a/docs/build-pieces/building-pieces/setup-fork.mdx b/docs/build-pieces/building-pieces/setup-fork.mdx index 7fef06325b84..258f621b0ead 100644 --- a/docs/build-pieces/building-pieces/setup-fork.mdx +++ b/docs/build-pieces/building-pieces/setup-fork.mdx @@ -13,7 +13,7 @@ If you are on windows, please install [WSL](https://learn.microsoft.com/en-us/wi 1. Go to the repository page at https://github.com/activepieces/activepieces. 2. Click the `Fork` button located in the top right corner of the page. -![Fork Repository](/resources/screenshots/fork-repository.jpg) +![Fork Repository](/resources/screenshots/fork-repository.png) 3. Clone your fork using a shallow clone for faster setup: diff --git a/docs/build-pieces/misc/bundling-pieces.mdx b/docs/build-pieces/misc/bundling-pieces.mdx index f12bf1663d1d..2ff310170f10 100644 --- a/docs/build-pieces/misc/bundling-pieces.mdx +++ b/docs/build-pieces/misc/bundling-pieces.mdx @@ -5,15 +5,15 @@ icon: 'cube' Activepieces builds every piece into a **self-contained bundle**. Instead of shipping a piece that depends on `@activepieces/shared`, `@activepieces/pieces-framework`, `@activepieces/pieces-common`, and the `@activepieces/core-*` packages at install time, the build inlines all of that code into a single artifact. -This is what lets the engine provision a piece by downloading **one artifact** — no `bun install` / `npm install` of a dependency tree at runtime. +This is what lets the engine provision a piece by downloading **one artifact**, with no `bun install` / `npm install` of a dependency tree at runtime. ### What gets bundled When a piece is built for publishing, the bundler: -- **Inlines all `@activepieces/*` workspace libraries** (`shared`, `pieces-framework`, `pieces-common`, `core-utils`, `core-piece-types`, …) directly into the bundle. These libraries are **never published to npm** — they only exist as part of each piece's bundle. +- **Inlines all `@activepieces/*` workspace libraries** (`shared`, `pieces-framework`, `pieces-common`, `core-utils`, `core-piece-types`, …) directly into the bundle. These libraries are **never published to npm**; they only exist as part of each piece's bundle. - **Inlines third-party dependencies** (e.g. an SDK the piece imports) into the same bundle. -- **Keeps a small allow-list of deps external** only when they genuinely cannot be inlined — native addons or packages that use dynamic `require`. These remain in the published `dependencies` so the runtime installer resolves them. +- **Keeps a small allow-list of deps external** only when they genuinely cannot be inlined: native addons or packages that use dynamic `require`. These remain in the published `dependencies` so the runtime installer resolves them. The result is typically **~2–3× smaller** than the raw inputs, and the published `package.json` lists only the few unavoidable external deps (e.g. `tslib`). @@ -33,7 +33,7 @@ After bundling, the piece's `dist/package.json` is rewritten: } ``` -No `@activepieces/*` dependency appears — installing the piece never requires those packages to exist on the registry. +No `@activepieces/*` dependency appears, so installing the piece never requires those packages to exist on the registry. ### Forcing a dependency to stay external @@ -48,7 +48,7 @@ If a dependency must not be inlined (for example a native module), add it to a ` ### Files loaded at runtime (forked processes) -A file your piece loads by path at runtime — for example `child_process.fork(path.join(__dirname, 'runner.js'))` — is invisible to the bundler's import graph, so by default it would not exist in the published package. Declare it in `bundleForkedEntries`: +A file your piece loads by path at runtime (for example `child_process.fork(path.join(__dirname, 'runner.js'))`) is invisible to the bundler's import graph, so by default it would not exist in the published package. Declare it in `bundleForkedEntries`: ```jsonc { @@ -57,7 +57,7 @@ A file your piece loads by path at runtime — for example `child_process.fork(p } ``` -Each declared entry is bundled on its own and emitted **next to the main bundle** at `src/.js` — which is where `path.join(__dirname, '.js')` resolves at runtime, since the bundled parent code lives at `src/index.js`. Dependencies that only the forked file imports (e.g. `oracledb`) are still captured into the published `dependencies`. +Each declared entry is bundled on its own and emitted **next to the main bundle** at `src/.js`, which is where `path.join(__dirname, '.js')` resolves at runtime, since the bundled parent code lives at `src/index.js`. Dependencies that only the forked file imports (e.g. `oracledb`) are still captured into the published `dependencies`. The bundler **fails the build** if piece code uses `__dirname` without declaring any `bundleForkedEntries`, because such code breaks silently after publishing. @@ -65,7 +65,7 @@ The bundler **fails the build** if piece code uses `__dirname` without declaring Bundling happens automatically when you build or publish a piece: -- `npm run build-piece ` — builds and packs the bundle into a `.tgz` (see [Build Custom Pieces](./build-piece)). -- `npm run publish-piece-to-api` — bundles and uploads the piece to a platform (see [Publish Custom Pieces](./publish-piece)). +- `npm run build-piece `: builds and packs the bundle into a `.tgz` (see [Build Custom Pieces](./build-piece)). +- `npm run publish-piece-to-api`: bundles and uploads the piece to a platform (see [Publish Custom Pieces](./publish-piece)). -You don't need to configure anything for bundling — it is the default behavior. +You don't need to configure anything for bundling; it is the default behavior. diff --git a/docs/build-pieces/misc/migrate-nx-to-turbo.mdx b/docs/build-pieces/misc/migrate-nx-to-turbo.mdx index 46ce603cfcfe..1bf9f5d9d222 100644 --- a/docs/build-pieces/misc/migrate-nx-to-turbo.mdx +++ b/docs/build-pieces/misc/migrate-nx-to-turbo.mdx @@ -23,9 +23,9 @@ The Activepieces monorepo replaced [Nx](https://nx.dev) with [Turbo](https://tur ### Files affected per piece -- **`project.json`** — Deleted (no longer needed) -- **`package.json`** — Added `build` and `lint` scripts, added `main` and `types` fields, added workspace dependencies -- **`tsconfig.lib.json`** — Updated `outDir`, added `rootDir`, `baseUrl`, `paths` +- **`project.json`**: Deleted (no longer needed) +- **`package.json`**: Added `build` and `lint` scripts, added `main` and `types` fields, added workspace dependencies +- **`tsconfig.lib.json`**: Updated `outDir`, added `rootDir`, `baseUrl`, `paths` ## Automatic Migration @@ -49,10 +49,10 @@ npx ts-node tools/scripts/migrate-custom-piece-to-turbo.ts packages/pieces/custo For each piece, the script: -1. **Updates `package.json`** — adds `build` and `lint` scripts, sets `main` and `types` entry points, ensures `@activepieces/pieces-framework`, `@activepieces/shared`, and `tslib` are listed as dependencies -2. **Updates `tsconfig.lib.json`** — sets `outDir` to `./dist`, adds `rootDir`, `baseUrl`, `paths`, and `declaration` settings -3. **Creates `tsconfig.json`** if missing — extends the root `tsconfig.base.json` -4. **Deletes `project.json`** — removes Nx configuration +1. **Updates `package.json`**: adds `build` and `lint` scripts, sets `main` and `types` entry points, ensures `@activepieces/pieces-framework`, `@activepieces/shared`, and `tslib` are listed as dependencies +2. **Updates `tsconfig.lib.json`**: sets `outDir` to `./dist`, adds `rootDir`, `baseUrl`, `paths`, and `declaration` settings +3. **Creates `tsconfig.json`** if missing, extending the root `tsconfig.base.json` +4. **Deletes `project.json`**, removing the Nx configuration ## Verify the Migration diff --git a/docs/build-pieces/misc/migrate-pieces-to-bundles.mdx b/docs/build-pieces/misc/migrate-pieces-to-bundles.mdx index 7a8f5658822d..8c01109a3b9d 100644 --- a/docs/build-pieces/misc/migrate-pieces-to-bundles.mdx +++ b/docs/build-pieces/misc/migrate-pieces-to-bundles.mdx @@ -4,10 +4,10 @@ icon: 'boxes-stacked' --- -This applies to forks with custom pieces created before pieces moved to self-contained bundles. Pieces created with `npm run cli pieces create` already use the bundle model — nothing to do. +This applies to forks with custom pieces created before pieces moved to self-contained bundles. Pieces created with `npm run cli pieces create` already use the bundle model, so there's nothing to do. -Activepieces builds each piece into a single self-contained bundle — see [Bundling Pieces](./bundling-pieces) for how that works. If you maintain custom pieces from before this change, update them so they conform to the new model. +Activepieces builds each piece into a single self-contained bundle (see [Bundling Pieces](./bundling-pieces) for how that works). If you maintain custom pieces from before this change, update them so they conform to the new model. ## What changed @@ -33,7 +33,7 @@ npm run cli -- pieces migrate ``` - Pass `--all` to migrate every piece under `packages/pieces`. -- It is idempotent — re-running it on an already-migrated piece makes no changes. +- It is idempotent: re-running it on an already-migrated piece makes no changes. Then build the piece to verify: @@ -41,7 +41,7 @@ Then build the piece to verify: npm run build-piece ``` -To migrate by hand — or to see exactly what the command changes — follow the steps below. +To migrate by hand, or to see exactly what the command changes, follow the steps below. ## What it changes (or migrate by hand) @@ -61,7 +61,7 @@ If a symbol isn't re-exported by the framework, it was server-only and shouldn't -Remove `@activepieces/shared` and move `tslib` to `devDependencies`. Add `@activepieces/core-piece-types` and `@activepieces/core-utils` — these are build-time only (they let `tsc` emit portable type declarations; your source never imports them directly). Add the `bundle` script. +Remove `@activepieces/shared` and move `tslib` to `devDependencies`. Add `@activepieces/core-piece-types` and `@activepieces/core-utils`, which are build-time only (they let `tsc` emit portable type declarations; your source never imports them directly). Add the `bundle` script. ```jsonc { diff --git a/docs/build-pieces/piece-reference/ai-metadata.mdx b/docs/build-pieces/piece-reference/ai-metadata.mdx index 6029473d6418..867a7d8f161b 100644 --- a/docs/build-pieces/piece-reference/ai-metadata.mdx +++ b/docs/build-pieces/piece-reference/ai-metadata.mdx @@ -4,7 +4,7 @@ icon: "robot" description: "Make your piece's actions and triggers discoverable and safe for AI agents" --- -Every action and trigger you build is also an AI tool: agents connected through the [MCP server](/mcp/overview) discover piece actions and execute them directly. Two optional fields control how your piece appears to agents — `aiMetadata` describes the operation in agent terms, and `audience` controls which surfaces an action shows up in. Both are additive: omitting them leaves the piece behaving exactly as before. +Every action and trigger you build is also an AI tool: agents connected through the [MCP server](/mcp/overview) discover piece actions and execute them directly. Two optional fields control how your piece appears to agents: `aiMetadata` describes the operation in agent terms, and `audience` controls which surfaces an action shows up in. Both are additive: omitting them leaves the piece behaving exactly as before. ## aiMetadata @@ -12,14 +12,14 @@ Available on both actions and triggers: ```typescript aiMetadata: { - description: string, // optional — agent-oriented description - idempotent: boolean, // optional — is repeating the call with the same input safe? + description: string, // optional, agent-oriented description + idempotent: boolean, // optional, is repeating the call with the same input safe? } ``` **`description`** is written for an agent, not for the UI. The regular `description` stays a short label under the action name in the builder; `aiMetadata.description` can be a full paragraph that states what the operation does, its notable options and constraints, and how it differs from sibling actions ("Use *Send Message To A User* for a private DM"). This text feeds the [tool search](/mcp/tool-search) index, so a precise description directly improves whether agents find your action. -**`idempotent`** declares whether calling the operation twice with the same input is safe. Reads, upserts, and set-value operations are idempotent; anything that creates, sends, or appends on every call is not. The value is exposed to agents and MCP clients as metadata that informs whether a retry is safe — it does not by itself prevent or trigger retries. +**`idempotent`** declares whether calling the operation twice with the same input is safe. Reads, upserts, and set-value operations are idempotent; anything that creates, sends, or appends on every call is not. The value is exposed to agents and MCP clients as metadata that informs whether a retry is safe; it does not by itself prevent or trigger retries. ```typescript import { createAction } from '@activepieces/pieces-framework'; @@ -46,7 +46,7 @@ export const createTask = createAction({ ## audience -Available on actions only (triggers have no audience — they always start flows, which both humans and agents build): +Available on actions only (triggers have no audience: they always start flows, which both humans and agents build): ```typescript audience: 'human' | 'ai' | 'both' @@ -58,10 +58,10 @@ audience: 'human' | 'ai' | 'both' | `human` | Shown | Hidden from agent discovery | | `ai` | Hidden from the piece selector | Shown | -Mark an action `human` when it only makes sense with the builder around it — for example the generic custom API call, or composite actions whose inputs assume a person picking from dropdowns. Mark an action `ai` for atomic operations added specifically for agents that would clutter the human piece selector. +Mark an action `human` when it only makes sense with the builder around it, for example the generic custom API call, or composite actions whose inputs assume a person picking from dropdowns. Mark an action `ai` for atomic operations added specifically for agents that would clutter the human piece selector. -`audience` is a discovery filter, not a permission. It controls which catalogs an action appears in — it does not prevent execution, so don't rely on it to keep a dangerous action away from agents. +`audience` is a discovery filter, not a permission. It controls which catalogs an action appears in; it does not prevent execution, so don't rely on it to keep a dangerous action away from agents. ## Writing actions agents can use well @@ -69,6 +69,6 @@ Mark an action `human` when it only makes sense with the builder around it — f Agents work best with actions that behave like clean API calls: - **Atomic over composite.** One action should map to one capability with explicit inputs. Agents compose multi-step work themselves, so a focused *Create Task* beats a *Create Task and Notify Channel*. -- **Explicit inputs.** Every behavior should be reachable through a documented prop — agents fill inputs from the [property schema](/build-pieces/piece-reference/properties), not from a UI. +- **Explicit inputs.** Every behavior should be reachable through a documented prop; agents fill inputs from the [property schema](/build-pieces/piece-reference/properties), not from a UI. - **Describe the output.** Pair the action with an [output schema](/build-pieces/piece-reference/output-schema) so both the data selector and agents know the shape of what comes back. -- **Disambiguate in `aiMetadata.description`.** When a piece has several similar actions, say which one to use when — that sentence is often what decides which action the search returns. +- **Disambiguate in `aiMetadata.description`.** When a piece has several similar actions, say which one to use when: that sentence is often what decides which action the search returns. diff --git a/docs/build-pieces/piece-reference/examples.mdx b/docs/build-pieces/piece-reference/examples.mdx index 1039f9ca2ab0..c639449733f8 100755 --- a/docs/build-pieces/piece-reference/examples.mdx +++ b/docs/build-pieces/piece-reference/examples.mdx @@ -10,6 +10,7 @@ To get the full benefit, it is recommended to read the tutorial first. **Webhooks:** - [New Form Submission on Typeform](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/typeform/src/lib/trigger/new-submission.ts) +- [New Event on Okta (with Handshake Configuration)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/okta/src/lib/triggers/new-event.ts) **Polling:** - [New Completed Task On Todoist](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/todoist/src/lib/triggers/task-completed-trigger.ts) @@ -29,3 +30,10 @@ To get the full benefit, it is recommended to read the tutorial first. **Basic Authentication:** - [Twilio](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/twilio/src/index.ts) + +**Custom Auth:** +- [Kimai](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/kimai/src/index.ts) + +**Custom Auth with Token Refresh:** +- [Umami](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/umami/src/lib/auth.ts) +- [OmniHR](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/omnihr/src/lib/auth.ts) diff --git a/docs/build-pieces/piece-reference/external-libraries.mdx b/docs/build-pieces/piece-reference/external-libraries.mdx index 9087cc339516..70034b135789 100644 --- a/docs/build-pieces/piece-reference/external-libraries.mdx +++ b/docs/build-pieces/piece-reference/external-libraries.mdx @@ -4,7 +4,7 @@ icon: 'npm' description: "Learn how to install and use external libraries." --- -The Activepieces repository is structured as a monorepo, employing Nx as its build tool. +The Activepieces repository is structured as a monorepo, employing Turbo as its build tool. To keep our main `package.json` as light as possible, we keep libraries that are only used for a piece in the piece `package.json` . This means when adding a new library you should navigate to the piece folder and install the library with our package manager `bun` @@ -21,7 +21,7 @@ Guidelines: ## Dependency Pinning -When pieces are built for publishing, all dependency versions — including **transitive dependencies** (dependencies of your dependencies) — are automatically pinned to the exact versions resolved in the monorepo's `bun.lock` file. +When pieces are built for publishing, all dependency versions, including **transitive dependencies** (dependencies of your dependencies), are automatically pinned to the exact versions resolved in the monorepo's `bun.lock` file. This means: - You don't need to worry about manually pinning transitive dependency versions. diff --git a/docs/build-pieces/piece-reference/files.mdx b/docs/build-pieces/piece-reference/files.mdx index bfc9f248f7b8..bc6ef4efe9d9 100644 --- a/docs/build-pieces/piece-reference/files.mdx +++ b/docs/build-pieces/piece-reference/files.mdx @@ -20,7 +20,7 @@ const fileReference = await files.write({ For large files, pass a `Readable` instead of a `Buffer` so the file streams to storage -without being held in memory — see [Large File Streaming](./large-file-streaming). +without being held in memory. See [Large File Streaming](./large-file-streaming). diff --git a/docs/build-pieces/piece-reference/flow-control.mdx b/docs/build-pieces/piece-reference/flow-control.mdx index 68f62139849a..e679d4550403 100644 --- a/docs/build-pieces/piece-reference/flow-control.mdx +++ b/docs/build-pieces/piece-reference/flow-control.mdx @@ -4,7 +4,7 @@ icon: 'Joystick' description: 'Learn how to control flow execution from inside a piece' --- -Flow Controls let an action change the shape of the run — stop it early, send an intermediate HTTP response, or **pause the flow and resume later** when an external signal arrives. All of these are exposed on the `ctx` parameter of the action's `run` method. +Flow Controls let an action change the shape of the run: stop it early, send an intermediate HTTP response, or **pause the flow and resume later** when an external signal arrives. All of these are exposed on the `ctx` parameter of the action's `run` method. ## Stop Flow @@ -30,9 +30,9 @@ context.run.stop(); ## Pause with a waitpoint -A **waitpoint** is a durable checkpoint: the run is marked `PAUSED`, its execution state is persisted, and the action will be invoked a second time once the waitpoint is resumed. Waitpoints survive worker restarts — see [Durable Execution](/install/architecture/durable-execution) for the full model. +A **waitpoint** is a durable checkpoint: the run is marked `PAUSED`, its execution state is persisted, and the action will be invoked a second time once the waitpoint is resumed. Waitpoints survive worker restarts; see [Durable Execution](/install/architecture/durable-execution) for the full model. -The same action runs twice — once to create the waitpoint, once to read the resume payload — so every pausing action branches on `ctx.executionType`: +The same action runs twice (once to create the waitpoint, once to read the resume payload), so every pausing action branches on `ctx.executionType`: ```typescript import { ExecutionType } from '@activepieces/shared'; @@ -41,7 +41,7 @@ async run(ctx) { if (ctx.executionType === ExecutionType.BEGIN) { // First invocation: create the waitpoint and pause. // A real WEBHOOK waitpoint must surface waitpoint.buildResumeUrl(...) to - // the outside world — see the "Wait for a webhook callback" section below. + // the outside world, see the "Wait for a webhook callback" section below. const waitpoint = await ctx.run.createWaitpoint({ type: 'WEBHOOK' }); ctx.run.waitForWaitpoint(waitpoint.id); return {}; @@ -58,14 +58,14 @@ async run(ctx) { Two hooks do the work: -- `ctx.run.createWaitpoint({ type, ... })` — registers the waitpoint on the server and returns `{ id, resumeUrl, buildResumeUrl }`. -- `ctx.run.waitForWaitpoint(waitpointId)` — tells the engine the step's verdict is `paused`; the run transitions to `PAUSED` after the action returns. +- `ctx.run.createWaitpoint({ type, ... })`: registers the waitpoint on the server and returns `{ id, resumeUrl, buildResumeUrl }`. +- `ctx.run.waitForWaitpoint(waitpointId)`: tells the engine the step's verdict is `paused`; the run transitions to `PAUSED` after the action returns. There are two waitpoint types. ### Wait for a webhook callback -Create a `WEBHOOK` waitpoint and expose its resume URL — the flow will resume whenever that URL is called. +Create a `WEBHOOK` waitpoint and expose its resume URL. The flow will resume whenever that URL is called. ```typescript async run(ctx) { @@ -93,7 +93,7 @@ async run(ctx) { ### Respond immediately and wait for the next webhook -Pause the flow **and** immediately reply to the webhook trigger — useful for "we got your submission, we'll call you back" patterns. Pass `responseToSend` and your HTTP response is sent right away; the flow then sits paused until the returned URL is called. +Pause the flow **and** immediately reply to the webhook trigger, useful for "we got your submission, we'll call you back" patterns. Pass `responseToSend` and your HTTP response is sent right away; the flow then sits paused until the returned URL is called. ```typescript async run(ctx) { @@ -159,7 +159,7 @@ ctx.resumePayload.headers // HTTP headers from the webhook caller ctx.resumePayload.queryParams // parsed ?foo=bar query string ``` -For `DELAY` waitpoints there is no incoming HTTP request, so the payload is empty — use the `RESUME` branch simply to produce the step's final output. +For `DELAY` waitpoints there is no incoming HTTP request, so the payload is empty. Use the `RESUME` branch simply to produce the step's final output. **Deprecated:** older pieces use `ctx.run.pause({ pauseMetadata: { type: PauseType.WEBHOOK | PauseType.DELAY, ... } })` together with `ctx.generateResumeUrl(...)`. That V0 API is kept for backwards compatibility with in-flight paused runs and will be removed. New actions must use `ctx.run.createWaitpoint` + `ctx.run.waitForWaitpoint`. diff --git a/docs/build-pieces/piece-reference/large-file-streaming.mdx b/docs/build-pieces/piece-reference/large-file-streaming.mdx index 9e9d1bb52d88..a5db6f163b67 100644 --- a/docs/build-pieces/piece-reference/large-file-streaming.mdx +++ b/docs/build-pieces/piece-reference/large-file-streaming.mdx @@ -4,8 +4,8 @@ icon: 'water' description: "Move large files through a flow without buffering them in memory" --- -Large file streaming lets a piece process a big file by passing it through as a stream — -transferring the bytes a chunk at a time — instead of loading the whole file into memory +Large file streaming lets a piece process a big file by passing it through as a stream, +transferring the bytes a chunk at a time, instead of loading the whole file into memory first. It is useful when a file is too large to hold in RAM at either the source or the destination. Because the file is never fully buffered, a flow can move files far larger than the worker's memory budget. @@ -13,19 +13,19 @@ than the worker's memory budget. ## Why it matters A worker runs your piece code inside a sandbox with a bounded memory budget (about 1 GB, -minus overhead — see [Limits](/install/reference/limits)). Reading a large file into a +minus overhead, see [Limits](/install/reference/limits)). Reading a large file into a `Buffer` holds the entire file in that budget at once, so a big enough file exhausts the memory and the worker is OOM-killed. Streaming avoids this: the bytes flow through as a Node `Readable`, roughly 5 MB at a -time, so no process ever holds the whole file. The transfer is transparent — there are no +time, so no process ever holds the whole file. The transfer is transparent: there are no extra "chunk" steps in your flow; a streaming action looks and behaves like any other. **Requires S3 file storage.** Full streaming only works when file storage is set to S3 (`AP_FILE_STORAGE_LOCATION=S3`). With the default database (`DB`) storage a stream **can't** be written into a column incrementally, so the server buffers the whole file in memory -before saving it — which defeats the memory savings and means very large files can still +before saving it, which defeats the memory savings and means very large files can still fail. Self-hosted installs default to `DB`; set S3 to get the benefit. See [Set up S3](/install/configure-operate/setup-s3). @@ -35,9 +35,9 @@ fail. Self-hosted installs default to `DB`; set S3 to get the benefit. See **Which approach should you use?** -- **Buffer** (a `Buffer`, the default) — small files of known size where holding the whole +- **Buffer** (a `Buffer`, the default): small files of known size where holding the whole file in memory is cheap and simple. -- **Stream** (a `Readable`) — large files, files of unknown size, or app-to-app transfers +- **Stream** (a `Readable`): large files, files of unknown size, or app-to-app transfers (e.g. downloading a large object from one service and uploading it to another) where buffering would risk exhausting memory. @@ -57,11 +57,11 @@ Streaming is enabled per action. "In" means the action reads its **input** file | Dropbox | Upload file | Download File | | Google Drive | Upload file | Read File Content, List files, Set public access, New File (trigger) | | Microsoft OneDrive | Upload file | Get File | -| Microsoft SharePoint | Upload File | — | +| Microsoft SharePoint | Upload File | N/A | | FTP/SFTP | Upload File | Read File Content | -| Subflows | Stream CSV to Subflows | — | +| Subflows | Stream CSV to Subflows | N/A | -More pieces are being enabled over time. Actions not listed here still work — they buffer +More pieces are being enabled over time. Actions not listed here still work: they buffer the file in memory, which is fine within the [size limit](/install/reference/limits). @@ -83,7 +83,7 @@ what a single request can carry, the action switches to a chunked upload session These are pre-existing API limits rather than streaming limits, and each action now handles its own. One difference matters if your source doesn't report a size: Dropbox's session is offset-based, so it just streams the chunks as they arrive, while Graph wants the file's total length in every fragment's -`Content-Range` header — so the two Microsoft actions buffer once to learn the length before they can +`Content-Range` header, so the two Microsoft actions buffer once to learn the length before they can chunk. Give those a source that reports `Content-Length` when you can. ## Building streaming actions @@ -93,8 +93,8 @@ input file as a stream, and write an output file as a stream. ### Writing a file as a stream -`ctx.files.write` accepts a `Buffer` **or** a `Readable`. Pass a `Readable` — such as an S3 -object body or a streaming HTTP response — and it streams straight to storage instead of +`ctx.files.write` accepts a `Buffer` **or** a `Readable`. Pass a `Readable`, such as an S3 +object body or a streaming HTTP response, and it streams straight to storage instead of being buffered. It returns a file reference string you return from the action, exactly like the buffered form (see [Files](./files)). @@ -104,7 +104,7 @@ async run(context) { const { Body } = await s3.getObject({ Bucket: bucket, Key: key }); - // Body is a Readable — hand it straight to files.write, no Buffer in between + // Body is a Readable: hand it straight to files.write, no Buffer in between return context.files.write({ fileName: key, data: Body, @@ -127,7 +127,7 @@ type ApStreamingFile = { ``` Consume `body` directly. How you hand it to the destination depends on what that destination's -client accepts — in order of preference: +client accepts, in order of preference: **1. A chunking uploader (best).** Accepts a stream of unknown length and buffers each part before sending it, so no content length is needed and parts are individually replayable. For S3 @@ -157,11 +157,11 @@ async run(context) { } ``` -**2. An SDK that takes a stream directly.** Some clients accept a `Readable` as-is — Google +**2. An SDK that takes a stream directly.** Some clients accept a `Readable` as-is: Google Drive's `media.body`, SFTP's `client.put`. Just pass `file.body`. **3. A single-request HTTP upload.** If the destination is a plain `PUT`/`POST` that needs an -explicit `Content-Length`, you have to use `file.size` — and `size` is best-effort, so this +explicit `Content-Length`, you have to use `file.size`, and `size` is best-effort, so this path needs a buffered fallback for when it is missing. Dropbox, SharePoint and OneDrive all look like this: @@ -178,13 +178,13 @@ if (file.size != null) { } ``` -`size` is informational and best-effort — it is `undefined` when the source reports no +`size` is informational and best-effort: it is `undefined` when the source reports no `Content-Length`, and it is also dropped when the response is compressed (`Content-Encoding: gzip`/`br`/`deflate`), because the decompressed body no longer matches the advertised length. Don't require it: prefer pattern 1 or 2, which never need it. -`Property.File()` without `streaming` is unchanged — it still resolves to an `ApFile` with a +`Property.File()` without `streaming` is unchanged: it still resolves to an `ApFile` with a `data` buffer, so existing actions keep working. @@ -195,9 +195,9 @@ advertised length. Don't require it: prefer pattern 1 or 2, which never need it. flow; exceeding it aborts the transfer and fails the step. See [Limits](/install/reference/limits). - **Reading a streamed input is not capped.** A `streaming: true` file input has no - `AP_MAX_FILE_SIZE_MB` ceiling — that is deliberate, since the point of the feature is to move + `AP_MAX_FILE_SIZE_MB` ceiling; that is deliberate, since the point of the feature is to move files larger than the cap out to an external service. -- **Storage backend.** Streaming end-to-end requires S3 file storage — see the callout at +- **Storage backend.** Streaming end-to-end requires S3 file storage; see the callout at the top of this page. @@ -207,7 +207,7 @@ advertised length. Don't require it: prefer pattern 1 or 2, which never need it. streamed `ctx.files.write` gets no S3-error fallback to database storage, and a step that fails after its stream is drained cannot simply be re-run against the same stream. - **`httpClient` does not retry a stream body at all.** The retry loop in `pieces-common` - reuses the body it serialized before the first attempt, and a stream is one-shot — retrying + reuses the body it serialized before the first attempt, and a stream is one-shot, so retrying would replay a drained stream and send a truncated body. So a request whose body is a `Readable` (or a `form-data` payload, which is streamed too) runs its `retries` setting as `0`. This applies to **any** piece sending a stream through `httpClient`, not just file diff --git a/docs/build-pieces/piece-reference/output-schema.mdx b/docs/build-pieces/piece-reference/output-schema.mdx index d8acf5d04b51..a933a640140e 100644 --- a/docs/build-pieces/piece-reference/output-schema.mdx +++ b/docs/build-pieces/piece-reference/output-schema.mdx @@ -7,7 +7,7 @@ description: 'Describe how a step output is presented in the builder' By default, the builder renders a step's output as raw JSON. You can opt in to a friendly, labelled presentation by declaring an `outputSchema` on your action or trigger. The schema drives both the **Smart Output Viewer** (test step / run details) and the **Data Selector** (variable -picker), giving users readable labels, formatted values, and nested/tabular views — without +picker), giving users readable labels, formatted values, and nested/tabular views, without changing the expression paths used in automations. The schema is fully type-checked against the `OutputSchema` type exported from @@ -56,7 +56,7 @@ When your output is an object keyed by UUIDs/slugs (`dynamicKey: true`) or an ar to point at a property **inside each entry/item** whose value should be shown as the label instead. -The expression path is never affected — `labelKey` only changes what the user sees. Inserting a +The expression path is never affected: `labelKey` only changes what the user sees. Inserting a variable still references the opaque key (`step_1['']`) or numeric index. This is ideal when keying by UUID: the key stays stable even if the name changes in the external system, so existing automations never break. @@ -109,7 +109,7 @@ view derives its headers from property keys. When your action returns a **top-level array** (e.g. a list of search results), the schema's `fields` describe a single item, and they are applied to every element of the array. Use the optional -`itemLabel` template to label each item — `{dotPath}` placeholders are resolved against that item: +`itemLabel` template to label each item. `{dotPath}` placeholders are resolved against that item: ```typescript outputSchema: { diff --git a/docs/build-pieces/piece-reference/piece-versioning.mdx b/docs/build-pieces/piece-reference/piece-versioning.mdx index dfe0cbf69a5a..74c8ac403b6b 100644 --- a/docs/build-pieces/piece-reference/piece-versioning.mdx +++ b/docs/build-pieces/piece-reference/piece-versioning.mdx @@ -14,9 +14,9 @@ This page is for *piece authors* deciding what version number to publish. For ho The version number is `MAJOR.MINOR.PATCH`: -- **MAJOR** — bump when you make a breaking change. -- **MINOR** — bump when you add functionality without breaking existing flows. -- **PATCH** — bump for bug fixes that don't change behavior. +- **MAJOR**: bump when you make a breaking change. +- **MINOR**: bump when you add functionality without breaking existing flows. +- **PATCH**: bump for bug fixes that don't change behavior. ## Classifying changes @@ -27,6 +27,7 @@ Use this checklist when deciding which segment to bump. - Remove an existing action or trigger. - Add a required prop to an existing action or trigger. - Remove an existing prop, whether required or optional. +- Rename an existing prop, or change its type/shape. - Remove an attribute from an action output. - Change the existing behavior of an action or trigger. diff --git a/docs/build-pieces/piece-reference/properties.mdx b/docs/build-pieces/piece-reference/properties.mdx index 6ee9c8430e07..b168084131f0 100644 --- a/docs/build-pieces/piece-reference/properties.mdx +++ b/docs/build-pieces/piece-reference/properties.mdx @@ -13,7 +13,7 @@ import { SegmentedTabsPreview, FilterBuilderPreview, SectionCardsPreview, } from '/snippets/prop-previews.jsx'; -Properties are used in actions and triggers to collect information from the user. They are also displayed to the user for input. Each property renders as a labelled field in the step settings form — the previews below show exactly what the user sees. +Properties are used in actions and triggers to collect information from the user. They are also displayed to the user for input. Each property renders as a labelled field in the step settings form; the previews below show exactly what the user sees. ## Basic Properties @@ -51,7 +51,7 @@ Property.LongText({ ### Rich Text -This property gives the user a formatting toolbar (bold, italic, underline, links, lists) and preserves `{{ variables }}` inserted from previous steps. Pair it with a sibling dropdown via `formatProperty` to let the user switch between **plain text** and **HTML** — the returned value is a plain string in the chosen format. +This property gives the user a formatting toolbar (bold, italic, underline, links, lists) and preserves `{{ variables }}` inserted from previous steps. Pair it with a sibling dropdown via `formatProperty` to let the user switch between **plain text** and **HTML**; the returned value is a plain string in the chosen format. @@ -174,7 +174,7 @@ Property.DateRange({ }); ``` -The value is `{ preset, after?, before? }`. Resolve it to concrete ISO bounds inside `run()` with `dateRangeUtils.resolve` — relative presets resolve against "now", so recurring flows roll the window forward: +The value is `{ preset, after?, before? }`. Resolve it to concrete ISO bounds inside `run()` with `dateRangeUtils.resolve`. Relative presets resolve against "now", so recurring flows roll the window forward: ```typescript import { dateRangeUtils } from '@activepieces/pieces-framework'; @@ -539,7 +539,7 @@ Every property accepts a few optional hints that fine-tune how it renders. They | `icon` | any prop | A named icon shown beside the field in the filter builder. | | `advanced: true` | any prop | Moves the field into the collapsible *Advanced* section. Props render in the main form by default. | -Every property renders in the main form by default, required or not. Set `advanced: true` on a secondary option to tuck it into the collapsible **Advanced** section — `advanced: false` is the default and has no effect. Avoid the flag on required props: the section starts collapsed, so a mandatory field hidden there only surfaces as a validation error. +Every property renders in the main form by default, required or not. Set `advanced: true` on a secondary option to tuck it into the collapsible **Advanced** section; `advanced: false` is the default and has no effect. Avoid the flag on required props: the section starts collapsed, so a mandatory field hidden there only surfaces as a validation error. **Half-width fields** @@ -558,7 +558,7 @@ Actions and triggers can declare `propertyGroups` to organize related fields. Ea ### Segmented tabs -`display: 'tabs'` groups a set of props into a segmented tab control — for example To / Cc / Bcc recipients. +`display: 'tabs'` groups a set of props into a segmented tab control, for example To / Cc / Bcc recipients. @@ -606,12 +606,12 @@ createAction({ ``` - A filter row is shown when its value is set, so there's nothing extra to persist. Give filters short `placeholder` hints and an `icon` so each row reads clearly. Note that a `builder` or `footer` group also switches off the *Advanced* section for the whole step — every prop lives in the builder. + A filter row is shown when its value is set, so there's nothing extra to persist. Give filters short `placeholder` hints and an `icon` so each row reads clearly. Note that a `builder` or `footer` group also switches off the *Advanced* section for the whole step: every prop lives in the builder. ### Sectioned cards -`display: 'section'` groups related props into titled cards — for example a *Send to* card and a *Message* card. Unlike tabs and the filter builder, sectioned layouts **keep the collapsible _Advanced_ section** for props outside the cards: an ungrouped prop still honours `advanced: true` — unless it is a checkbox `reveals` target, which renders inline under its toggle instead. Props inside a section are always essential. Give each group a `label` and `icon`, and use `width: 'half'` on members to pack two fields per row. +`display: 'section'` groups related props into titled cards, for example a *Send to* card and a *Message* card. Unlike tabs and the filter builder, sectioned layouts **keep the collapsible _Advanced_ section** for props outside the cards: an ungrouped prop still honours `advanced: true`, unless it is a checkbox `reveals` target, which renders inline under its toggle instead. Props inside a section are always essential. Give each group a `label` and `icon`, and use `width: 'half'` on members to pack two fields per row. diff --git a/docs/build-pieces/piece-reference/triggers/webhook-trigger.mdx b/docs/build-pieces/piece-reference/triggers/webhook-trigger.mdx index 80f13a281259..8e8dca189372 100644 --- a/docs/build-pieces/piece-reference/triggers/webhook-trigger.mdx +++ b/docs/build-pieces/piece-reference/triggers/webhook-trigger.mdx @@ -9,7 +9,9 @@ The way webhook triggers usually work is as follows: Use `context.webhookUrl` to perform an HTTP request to register the webhook in a third-party app, and store the webhook Id in the `store`. **On Handshake:** -Some services require a successful handshake request usually consisting of some challenge. It works similar to a normal run except that you return the correct challenge response. This is optional and in order to enable the handshake you need to configure one of the available handshake strategies in the `handshakeConfiguration` option. +Some services require a successful handshake request usually consisting of some challenge. It works similar to a normal run except that you return the correct challenge response. This is optional and in order to enable the handshake you need to configure one of the available handshake strategies in the `handshakeConfiguration` option, and implement `onHandshake` to return the expected `WebhookResponse` (`{ status, body?, headers? }`). + +Available `WebhookHandshakeStrategy` values: `NONE` (default), `HEADER_PRESENT`, `QUERY_PRESENT`, `BODY_PARAM_PRESENT`, and `HEAD_REQUEST`. For the `*_PRESENT` strategies, also set `paramName` to the header/query/body key to check. **Run:** You can find the HTTP body inside `context.payload.body`. If needed, alter the body; otherwise, return an array with a single item `context.payload.body`. @@ -17,6 +19,68 @@ You can find the HTTP body inside `context.payload.body`. If needed, alter the b **Disable:** Using the `context.store`, fetch the webhook ID from the enable step and delete the webhook on the third-party app. +**Full Example:** + +```ts +import { createTrigger, TriggerStrategy, WebhookHandshakeStrategy } from '@activepieces/pieces-framework'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; + +export const newEvent = createTrigger({ + auth: someAuth, + name: 'new_event', + displayName: 'New Event', + description: 'Fires when a new event is generated', + type: TriggerStrategy.WEBHOOK, + props: {}, + sampleData: { + id: 'evt_123', + type: 'event.created', + }, + + // Called once when the flow is published; register the webhook with the third-party app. + async onEnable(context) { + const response = await httpClient.sendRequest<{ id: string }>({ + method: HttpMethod.POST, + url: 'https://api.example.com/webhooks', + body: { + url: context.webhookUrl, + }, + }); + await context.store.put('webhookId', response.body.id); + }, + + // Called once when the flow is disabled; deregister the webhook. + async onDisable(context) { + const webhookId = await context.store.get('webhookId'); + if (webhookId) { + await httpClient.sendRequest({ + method: HttpMethod.DELETE, + url: `https://api.example.com/webhooks/${webhookId}`, + }); + } + }, + + // Optional; only needed if the third-party app requires a verification challenge + // when the webhook URL is first registered. + handshakeConfiguration: { + strategy: WebhookHandshakeStrategy.HEADER_PRESENT, + paramName: 'x-verification-challenge', + }, + async onHandshake(context) { + const challenge = context.payload.headers['x-verification-challenge']; + return { + status: 200, + body: { challenge }, + }; + }, + + // Called on every incoming webhook request once the trigger is live. + async run(context) { + return [context.payload.body]; + }, +}); +``` + **Testing:** You cannot test it with Test Flow, as it uses static sample data provided in the piece. To test the trigger, publish the flow, perform the event. Then check the flow runs from the main dashboard. diff --git a/docs/build-pieces/sharing-pieces/community.mdx b/docs/build-pieces/sharing-pieces/community.mdx index 2ae7df35d273..e8ae334c795a 100644 --- a/docs/build-pieces/sharing-pieces/community.mdx +++ b/docs/build-pieces/sharing-pieces/community.mdx @@ -13,7 +13,7 @@ You can publish your pieces to the npm registry and share them with the communit ``` - Rename the piece name in `package.json` to something unique or related to your organization's scope (e.g., `@my-org/piece-PIECE_NAME`). You can find it at `packages/pieces/PIECE_NAME/package.json`. + Rename the piece name in `package.json` to something unique or related to your organization's scope (e.g., `@my-org/piece-PIECE_NAME`). You can find it at `packages/pieces/community/PIECE_NAME/package.json`. Don't forget to increase the version number in `package.json` for each new release. diff --git a/docs/build-pieces/sharing-pieces/private.mdx b/docs/build-pieces/sharing-pieces/private.mdx index 028cc5737ed8..81a246a9d09a 100644 --- a/docs/build-pieces/sharing-pieces/private.mdx +++ b/docs/build-pieces/sharing-pieces/private.mdx @@ -26,7 +26,7 @@ Friendly Tip: There is a CLI to easily upload it to your platform. Please check - Upload the generated tarball inside `dist/packages/pieces/${name}`from Activepieces Platform Admin -> Pieces + Upload the generated tarball from `packages/pieces/custom/${name}/dist` from Activepieces Platform Admin -> Pieces ![Manage Pieces](/resources/screenshots/install-piece.png) diff --git a/docs/resources/screenshots/development-setup_codespaces.png b/docs/resources/screenshots/development-setup_codespaces.png index 27e6a2c82b16..1ab08643e248 100644 Binary files a/docs/resources/screenshots/development-setup_codespaces.png and b/docs/resources/screenshots/development-setup_codespaces.png differ diff --git a/docs/resources/screenshots/fork-repository.jpg b/docs/resources/screenshots/fork-repository.jpg deleted file mode 100644 index e7f755f721ad..000000000000 Binary files a/docs/resources/screenshots/fork-repository.jpg and /dev/null differ diff --git a/docs/resources/screenshots/fork-repository.png b/docs/resources/screenshots/fork-repository.png new file mode 100644 index 000000000000..b6a6832d8fe7 Binary files /dev/null and b/docs/resources/screenshots/fork-repository.png differ diff --git a/docs/resources/screenshots/install-piece.png b/docs/resources/screenshots/install-piece.png index 46ccb0ab5642..d7efe3189f29 100644 Binary files a/docs/resources/screenshots/install-piece.png and b/docs/resources/screenshots/install-piece.png differ diff --git a/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts b/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts index 4e6d04aff5bf..a49f5868ac22 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/copy-file.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { fileMetadataOutputSchema } from '../output-schemas'; export const dropboxCopyFile = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxCopyFile = createAction({ audience: 'both', aiMetadata: { description: 'Copies the file at the source path to a new destination path within Dropbox, leaving the original in place; optionally autorenames on conflict. Use to duplicate a single file. Not idempotent: each call creates a copy, so repeating it errors on conflict or, with autorename, produces additional duplicates.', idempotent: false }, displayName: 'Copy file', + outputSchema: fileMetadataOutputSchema, props: { from_path: Property.ShortText({ displayName: 'From Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts index 7641bded4ef3..3230d77aef57 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/copy-folder.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { folderMetadataOutputSchema } from '../output-schemas'; export const dropboxCopyFolder = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxCopyFolder = createAction({ audience: 'both', aiMetadata: { description: 'Copies the folder at the source path, including its contents, to a new destination path within Dropbox, leaving the original in place; optionally autorenames on conflict. Use to duplicate an entire directory. Not idempotent: each call creates a copy, so repeating it errors on conflict or, with autorename, produces additional duplicates.', idempotent: false }, displayName: 'Copy folder', + outputSchema: folderMetadataOutputSchema, props: { from_path: Property.ShortText({ displayName: 'From Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/create-new-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/create-new-folder.ts index b44b4e9ba49a..7555b90c7d1c 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/create-new-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/create-new-folder.ts @@ -6,6 +6,7 @@ import { httpClient, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { folderMetadataOutputSchema } from '../output-schemas'; export const dropboxCreateNewFolder = createAction({ auth: dropboxAuth, @@ -15,6 +16,7 @@ export const dropboxCreateNewFolder = createAction({ audience: 'both', aiMetadata: { description: 'Creates a new empty folder at the given Dropbox path; optionally autorenames on conflict. Use to set up a destination directory before placing files. Not idempotent: a repeat call for an existing path errors (or, with autorename, creates a differently named folder).', idempotent: false }, displayName: 'Create New Folder', + outputSchema: folderMetadataOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/create-new-text-file.ts b/packages/pieces/community/dropbox/src/lib/actions/create-new-text-file.ts index f3b5b7af0f05..8552c5278f5b 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/create-new-text-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/create-new-text-file.ts @@ -6,6 +6,7 @@ import { httpClient, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { uploadedFileOutputSchema } from '../output-schemas'; export const dropboxCreateNewTextFile = createAction({ auth: dropboxAuth, @@ -15,6 +16,7 @@ export const dropboxCreateNewTextFile = createAction({ audience: 'both', aiMetadata: { description: 'Writes the provided text content to a new file at the given Dropbox path (upload in add mode). Use when an agent needs to persist generated or supplied text directly as a file without first producing a file object. Not idempotent: each call uploads, so repeating it can create autorenamed duplicates rather than overwriting.', idempotent: false }, displayName: 'Create New Text File', + outputSchema: uploadedFileOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts b/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts index 3650776e9b15..b9f1a1dffa08 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/delete-file.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { fileMetadataOutputSchema } from '../output-schemas'; export const dropboxDeleteFile = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxDeleteFile = createAction({ audience: 'both', aiMetadata: { description: 'Permanently deletes the file at the given Dropbox path. Use to remove a specific file an agent has resolved the path for. Effectively idempotent on the end state once the path is gone, but a repeat call fails because the path no longer exists; treat as a destructive, non-recoverable mutation.', idempotent: false }, displayName: 'Delete file', + outputSchema: fileMetadataOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts index 97c7a5c7f2b1..954d9f06c3df 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/delete-folder.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { folderMetadataOutputSchema } from '../output-schemas'; export const dropboxDeleteFolder = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxDeleteFolder = createAction({ audience: 'both', aiMetadata: { description: 'Permanently deletes the folder at the given Dropbox path along with all of its contents. Use to remove an entire directory. Effectively idempotent on the end state once the path is gone, but a repeat call fails because the path no longer exists; treat as a destructive, non-recoverable mutation.', idempotent: false }, displayName: 'Delete folder', + outputSchema: folderMetadataOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/download-file.ts b/packages/pieces/community/dropbox/src/lib/actions/download-file.ts index db9a30f055b9..92a0c6f63631 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/download-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/download-file.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { downloadFileOutputSchema } from '../output-schemas'; export const dropboxDownloadFile = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxDownloadFile = createAction({ description: 'Download a File from Dropbox', audience: 'both', aiMetadata: { description: 'Downloads the file at the given Dropbox path and returns it as a file object for use by later steps. Use to retrieve file contents into a flow. Read-only on Dropbox; repeating the call with the same path is safe and yields the same file.', idempotent: true }, + outputSchema: downloadFileOutputSchema, props: { path: Property.ShortText({ displayName: "Path", diff --git a/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts b/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts index 8c77c1f034ce..f46713012c68 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/get-file-link.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { getFileLinkOutputSchema } from '../output-schemas'; export const dropboxGetFileLink = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxGetFileLink = createAction({ audience: 'both', aiMetadata: { description: 'Returns a temporary, directly downloadable URL for the file at the given Dropbox path. Use when an agent needs a shareable or fetchable link to file contents rather than downloading the bytes into the flow. Read-only lookup; safe to repeat, though the returned URL is short-lived.', idempotent: true }, displayName: 'Get temporary file link', + outputSchema: getFileLinkOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/list-a-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/list-a-folder.ts index a3cd538442ae..e93f29e71dcc 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/list-a-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/list-a-folder.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { listFolderOutputSchema } from '../output-schemas'; export const dropboxListAFolder = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxListAFolder = createAction({ audience: 'both', aiMetadata: { description: 'Lists the files and subfolders within the given Dropbox folder path (use an empty string for the root), optionally recursing into all subfolders. Use to enumerate folder contents and discover item paths/IDs. Read-only; safe to repeat and returns the same listing for unchanged folders.', idempotent: true }, displayName: 'List a folder', + outputSchema: listFolderOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/move-file.ts b/packages/pieces/community/dropbox/src/lib/actions/move-file.ts index 43196eea7aaf..2709a7fccb99 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/move-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/move-file.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { fileMetadataOutputSchema } from '../output-schemas'; export const dropboxMoveFile = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxMoveFile = createAction({ audience: 'both', aiMetadata: { description: 'Moves (or renames) the file at the source path to a new destination path within Dropbox; optionally autorenames on conflict. Use to relocate or rename a single file. Not idempotent: after a successful move the source no longer exists, so repeating the call fails or, with autorename, can produce a differently named copy.', idempotent: false }, displayName: 'Move file', + outputSchema: fileMetadataOutputSchema, props: { from_path: Property.ShortText({ displayName: 'From Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts b/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts index 65c2a3c3329f..ebeb41b98092 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/move-folder.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { folderMetadataOutputSchema } from '../output-schemas'; export const dropboxMoveFolder = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxMoveFolder = createAction({ audience: 'both', aiMetadata: { description: 'Moves (or renames) the folder at the source path, including its contents, to a new destination path within Dropbox; optionally autorenames on conflict. Use to relocate or rename an entire directory. Not idempotent: after a successful move the source no longer exists, so repeating the call fails or, with autorename, produces a differently named folder.', idempotent: false }, displayName: 'Move folder', + outputSchema: folderMetadataOutputSchema, props: { from_path: Property.ShortText({ displayName: 'From Path', diff --git a/packages/pieces/community/dropbox/src/lib/actions/search.ts b/packages/pieces/community/dropbox/src/lib/actions/search.ts index f776fc02c759..feca9b38c717 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/search.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/search.ts @@ -5,6 +5,7 @@ import { AuthenticationType, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { searchOutputSchema } from '../output-schemas'; export const dropboxSearch = createAction({ auth: dropboxAuth, @@ -14,6 +15,7 @@ export const dropboxSearch = createAction({ audience: 'both', aiMetadata: { description: 'Searches a Dropbox account for files and folders whose name or content matches a query string (minimum 3 characters), optionally scoped to a path and filtered by file status, extensions, categories, or account. Use to locate items and obtain their paths/IDs before acting on them. Read-only; repeating the same search is safe and returns the same matches.', idempotent: true }, displayName: 'Search', + outputSchema: searchOutputSchema, props: { query: Property.ShortText({ displayName: 'Query', diff --git a/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts b/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts index d423a0694d78..b340f345a8b6 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts @@ -7,6 +7,7 @@ import { streamUtils, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { uploadedFileOutputSchema } from '../output-schemas'; const CONTENT_API_URL = 'https://content.dropboxapi.com/2'; const SINGLE_REQUEST_LIMIT = 150 * 1024 * 1024; @@ -20,6 +21,7 @@ export const dropboxUploadFile = createAction({ audience: 'both', aiMetadata: { description: 'Uploads a file (provided as a URL or base64 file object) to the given Dropbox path in add mode. Files over 150 MB are uploaded in chunks automatically. Use to store binary or arbitrary file content; prefer the create-text-file action when the source is plain text. Not idempotent: each call uploads, so repeating it can create autorenamed duplicates rather than overwriting.', idempotent: false }, displayName: 'Upload file', + outputSchema: uploadedFileOutputSchema, props: { path: Property.ShortText({ displayName: 'Path', diff --git a/packages/pieces/community/dropbox/src/lib/output-schemas.ts b/packages/pieces/community/dropbox/src/lib/output-schemas.ts new file mode 100644 index 000000000000..dfb9c5f5a8ba --- /dev/null +++ b/packages/pieces/community/dropbox/src/lib/output-schemas.ts @@ -0,0 +1,71 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const entryFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Name' }, + { key: 'id', label: 'ID' }, + { key: 'path_display', label: 'Path' }, + { key: 'path_lower', label: 'Path (Lowercase)' }, +]; + +const fileFields: OutputSchema['fields'] = [ + ...entryFields, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'client_modified', label: 'Client Modified', format: 'datetime' }, + { key: 'server_modified', label: 'Server Modified', format: 'datetime' }, + { key: 'rev', label: 'Revision' }, + { key: 'content_hash', label: 'Content Hash' }, + { key: 'is_downloadable', label: 'Is Downloadable', format: 'boolean' }, +]; + +export const uploadedFileOutputSchema: OutputSchema = { fields: fileFields }; + +export const fileMetadataOutputSchema: OutputSchema = { + fields: [{ key: 'metadata', label: 'File', children: fileFields }], +}; + +export const folderMetadataOutputSchema: OutputSchema = { + fields: [{ key: 'metadata', label: 'Folder', children: entryFields }], +}; + +export const getFileLinkOutputSchema: OutputSchema = { + fields: [ + { key: 'link', label: 'Temporary Link', format: 'url' }, + { key: 'metadata', label: 'File', children: fileFields }, + ], +}; + +export const downloadFileOutputSchema: OutputSchema = { + fields: [ + { + key: 'file', + label: 'File', + format: 'url', + description: 'Signed download URL for the retrieved file.', + }, + ], +}; + +export const listFolderOutputSchema: OutputSchema = { + fields: [ + { key: 'entries', label: 'Entries', labelKey: 'name', listItems: entryFields }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'cursor', label: 'Cursor' }, + ], +}; + +export const searchOutputSchema: OutputSchema = { + fields: [ + { + key: 'matches', + label: 'Matches', + labelKey: 'metadata.metadata.name', + listItems: entryFields.map((field) => ({ + ...field, + value: `metadata.metadata.${field.value ?? field.key}`, + })), + }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + ], +}; + +export const newFolderTriggerOutputSchema: OutputSchema = { fields: entryFields }; diff --git a/packages/pieces/community/dropbox/src/lib/triggers/new-folder.ts b/packages/pieces/community/dropbox/src/lib/triggers/new-folder.ts index 620bbe61e78f..db0c77171eee 100644 --- a/packages/pieces/community/dropbox/src/lib/triggers/new-folder.ts +++ b/packages/pieces/community/dropbox/src/lib/triggers/new-folder.ts @@ -10,6 +10,7 @@ import { httpClient, } from '@activepieces/pieces-common'; import { dropboxAuth } from '../auth'; +import { newFolderTriggerOutputSchema } from '../output-schemas'; type DropboxListFolderEntry = { id: string; @@ -109,6 +110,7 @@ export const dropboxNewFolder = createTrigger({ type: TriggerStrategy.POLLING, + outputSchema: newFolderTriggerOutputSchema, props: { path: Property.ShortText({ displayName: 'Watched Folder Path', diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-associations.ts b/packages/pieces/community/hubspot/src/lib/actions/create-associations.ts index 6bed1c1ef563..d6fe6bf13a27 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-associations.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-associations.ts @@ -9,6 +9,7 @@ import { OBJECT_TYPE } from '../common/constants'; import { Client } from '@hubspot/api-client'; import { AssociationSpecAssociationCategoryEnum } from '../common/types'; import { chunk } from '@activepieces/pieces-framework'; +import { createAssociationsOutputSchema } from '../output-schemas'; export const createAssociationsAction = createAction({ auth: hubspotAuth, @@ -18,6 +19,7 @@ export const createAssociationsAction = createAction({ description: 'Creates associations between objects', audience: 'both', aiMetadata: { description: 'Link one source HubSpot object (e.g. a company) to one or more target objects using a specific association type, batching the targets. Re-running with the same inputs re-applies the same association without creating duplicates. Use Remove Associations to undo a link.', idempotent: true }, + outputSchema: createAssociationsOutputSchema, props: { fromObjectId: Property.ShortText({ displayName: 'From Object ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-blog-post.ts b/packages/pieces/community/hubspot/src/lib/actions/create-blog-post.ts index 4d2a5f08d5e5..035179c8892a 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-blog-post.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-blog-post.ts @@ -2,6 +2,7 @@ import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces import { hubspotAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { blogAuthorDropdown, blogUrlDropdown } from '../common/props'; +import { createBlogPostOutputSchema } from '../output-schemas'; export const createBlogPostAction = createAction({ auth: hubspotAuth, @@ -11,6 +12,7 @@ export const createBlogPostAction = createAction({ description: 'Creates a blog post in you Hubspot COS blog.', audience: 'both', aiMetadata: { description: 'Create a post in a HubSpot CMS (COS) blog with title, slug, body, and featured image, then optionally publish it immediately when Status is set to publish rather than draft. Each call creates a new post, so it is not idempotent.', idempotent: false }, + outputSchema: createBlogPostOutputSchema, props: { contentGroupId: blogUrlDropdown, authorId: blogAuthorDropdown, diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-company.ts b/packages/pieces/community/hubspot/src/lib/actions/create-company.ts index f74aafbd4485..8eeb033a5b00 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-company.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-company.ts @@ -4,6 +4,7 @@ import { getDefaultPropertiesForObject, standardObjectDynamicProperties, standar import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createCompanyAction = createAction({ auth: hubspotAuth, @@ -13,6 +14,7 @@ export const createCompanyAction = createAction({ description: 'Creates a company in Hubspot.', audience: 'both', aiMetadata: { description: 'Create a new HubSpot company record from the supplied properties (name, domain, industry, etc.). Always inserts a new company even if one with the same domain already exists, so it is not idempotent; to change an existing record use Update Company, and to locate one first use a find action.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { objectProperties: standardObjectDynamicProperties(OBJECT_TYPE.COMPANY, []), markdown: Property.MarkDown({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-contact.ts b/packages/pieces/community/hubspot/src/lib/actions/create-contact.ts index 8e55d8b64236..a46c89b9a43a 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-contact.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; import { getDefaultPropertiesForObject, standardObjectDynamicProperties, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createContactAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const createContactAction = createAction({ description: 'Creates a contact in Hubspot.', audience: 'both', aiMetadata: { description: 'Creates a new contact record in HubSpot from the supplied property values, then returns the created contact. Use when you specifically need a new contact; to avoid duplicates when a contact may already exist, prefer Create or Update Contact, which upserts on email. Not idempotent: each call creates a separate contact.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { objectProperties: standardObjectDynamicProperties(OBJECT_TYPE.CONTACT, []), markdown: Property.MarkDown({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-custom-object.ts b/packages/pieces/community/hubspot/src/lib/actions/create-custom-object.ts index c79b192bbba0..e570a411636b 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-custom-object.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-custom-object.ts @@ -8,6 +8,7 @@ import { } from '../common/props'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createCustomObjectAction = createAction({ auth: hubspotAuth, @@ -17,6 +18,7 @@ export const createCustomObjectAction = createAction({ description: 'Creates a custom object in Hubspot.', audience: 'both', aiMetadata: { description: 'Create a new record of a selected HubSpot custom object type from the supplied properties. Each call inserts a new record, so it is not idempotent. Requires choosing the custom object type; use Find Custom Object to locate an existing record.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { customObjectType: customObjectDropdown, objectProperties: customObjectDynamicProperties, diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-deal.ts b/packages/pieces/community/hubspot/src/lib/actions/create-deal.ts index f77373b6d930..a200a62cfa00 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-deal.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-deal.ts @@ -13,6 +13,7 @@ import { } from '../common/props'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createDealAction = createAction({ auth: hubspotAuth, @@ -22,6 +23,7 @@ export const createDealAction = createAction({ description: 'Creates a new deal in Hubspot.', audience: 'both', aiMetadata: { description: 'Creates a new deal in HubSpot, requiring a deal name plus a pipeline and stage, and returns the created deal. Use to open a new opportunity; use Update Deal to modify an existing one. Not idempotent: each call creates a separate deal, so guard against duplicates.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { dealname: Property.ShortText({ displayName: 'Deal Name', diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-line-item.ts b/packages/pieces/community/hubspot/src/lib/actions/create-line-item.ts index 00f713f76a31..2eb82ac4c829 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-line-item.ts @@ -10,6 +10,7 @@ import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createLineItemAction = createAction({ auth: hubspotAuth, @@ -19,6 +20,7 @@ export const createLineItemAction = createAction({ description: 'Creates a line item in Hubspot.', audience: 'both', aiMetadata: { description: 'Creates a new standalone line item in HubSpot from a required product plus optional property values (quantity, price, discount), and returns the created line item. Use when building out a quote or deal\'s line items. Not idempotent: each call creates a separate line item.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { productId: productDropdown({ displayName: 'Line Item Information: Product ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-or-update-contact.ts b/packages/pieces/community/hubspot/src/lib/actions/create-or-update-contact.ts index eb4dce0b0f9d..08f847765d67 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-or-update-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-or-update-contact.ts @@ -5,6 +5,7 @@ import { Client } from '@hubspot/api-client'; import { standardObjectDynamicProperties } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; import { FilterOperatorEnum } from '../common/types'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createOrUpdateContactAction = createAction({ @@ -15,6 +16,7 @@ export const createOrUpdateContactAction = createAction({ description: 'Creates a new contact or updates an existing contact based on email address.', audience: 'both', aiMetadata: { description: 'Upserts a contact keyed on email: searches for a contact with the given email and updates it if found, otherwise creates a new one with the provided properties. Use this safe-to-retry path when you want to set a contact by email without risking duplicates; use Create Contact when you specifically need a new record. Idempotent on the email key.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { email: Property.ShortText({ displayName: 'Contact Email', diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-page.ts b/packages/pieces/community/hubspot/src/lib/actions/create-page.ts index 714c4e09203c..8c8259a81f9b 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-page.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-page.ts @@ -2,6 +2,7 @@ import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces import { hubspotAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { pageType } from '../common/props'; +import { pageOutputSchema } from '../output-schemas'; export const createPageAction = createAction({ auth: hubspotAuth, @@ -11,6 +12,7 @@ export const createPageAction = createAction({ description: 'Creates a new landing/site page.', audience: 'both', aiMetadata: { description: 'Create a new HubSpot CMS landing page or site page (choose via Page Type) from a template, then optionally publish it when State is set to publish rather than leaving it as a draft. Each call creates a distinct page, so it is not idempotent.', idempotent: false }, + outputSchema: pageOutputSchema, props: { pageType: pageType, pageTitle: Property.ShortText({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-product.ts b/packages/pieces/community/hubspot/src/lib/actions/create-product.ts index 93ca74d0f850..b9c84084fbad 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-product.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-product.ts @@ -8,6 +8,7 @@ import { import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createProductAction = createAction({ auth: hubspotAuth, @@ -21,6 +22,7 @@ export const createProductAction = createAction({ 'Create a new product record in the HubSpot product library from the supplied properties (such as name, price, and description). Use when adding a catalog item; each call always creates a separate product, so repeated calls produce duplicates rather than updating an existing one.', idempotent: false, }, + outputSchema: crmObjectOutputSchema, props: { objectProperties: standardObjectDynamicProperties(OBJECT_TYPE.PRODUCT,[]), markdown: Property.MarkDown({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/create-ticket.ts b/packages/pieces/community/hubspot/src/lib/actions/create-ticket.ts index 4eaf44934bfb..31763a86ba62 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/create-ticket.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/create-ticket.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, pipelineDropdown, pipelineStageDropdown, standardObjectDynamicProperties, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const createTicketAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const createTicketAction = createAction({ description: 'Creates a ticket in HubSpot.', audience: 'both', aiMetadata: { description: 'Create a new HubSpot support ticket with a name, pipeline, and pipeline stage plus optional properties. Each call creates a separate ticket even for identical input, so it is not idempotent.', idempotent: false }, + outputSchema: crmObjectOutputSchema, props: { ticketName: Property.ShortText({ displayName: 'Ticket Name', diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-associations.ts b/packages/pieces/community/hubspot/src/lib/actions/find-associations.ts index 035af7454fb5..a4a49e50e101 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-associations.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-associations.ts @@ -3,6 +3,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { fromObjectTypeAssociationDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; import { Client } from '@hubspot/api-client'; +import { findAssociationsOutputSchema } from '../output-schemas'; export const findAssociationsAction = createAction({ auth: hubspotAuth, @@ -12,6 +13,7 @@ export const findAssociationsAction = createAction({ description: 'Finds associations between objects', audience: 'both', aiMetadata: { description: 'Lists all associations from one CRM object to objects of another type (e.g. a company to its contacts or deals), paging through every result. Use to discover what records are linked to a known object given its ID and the from/to object types. Read-only and idempotent.', idempotent: true }, + outputSchema: findAssociationsOutputSchema, props: { fromObjectId: Property.ShortText({ displayName: 'From Object ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-company.ts b/packages/pieces/community/hubspot/src/lib/actions/find-company.ts index ce6ec181243e..a13aac05ea6b 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-company.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-company.ts @@ -8,6 +8,7 @@ import { import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { Client } from '@hubspot/api-client'; import { FilterOperatorEnum } from '../common/types'; +import { companySearchOutputSchema } from '../output-schemas'; export const findCompanyAction = createAction({ auth: hubspotAuth, @@ -17,6 +18,7 @@ export const findCompanyAction = createAction({ description: 'Finds a company by searching.', audience: 'both', aiMetadata: { description: 'Searches companies via the HubSpot CRM search API, matching on one or two property name/value pairs (exact match, combined as AND), and returns matching companies. Use to locate a company by domain, name, or another property before reading or updating it; prefer Get Company when you already have the company ID. Read-only and idempotent.', idempotent: true }, + outputSchema: companySearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-contact.ts b/packages/pieces/community/hubspot/src/lib/actions/find-contact.ts index f874e3a21711..be5eebc52ec0 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-contact.ts @@ -5,6 +5,7 @@ import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { FilterOperatorEnum } from '../common/types'; +import { contactSearchOutputSchema } from '../output-schemas'; export const findContactAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const findContactAction = createAction({ description: 'Finds a contact by searching.', audience: 'both', aiMetadata: { description: 'Search HubSpot contacts by one or two property/value pairs (matched with equality) and return the matching contacts. Read-only and repeatable. Use this to resolve a contact before updating or enrolling it; pick a create action instead when no match should exist.', idempotent: true }, + outputSchema: contactSearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-custom-object.ts b/packages/pieces/community/hubspot/src/lib/actions/find-custom-object.ts index 2990770e1520..436873823017 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-custom-object.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-custom-object.ts @@ -5,6 +5,7 @@ import { hubspotAuth } from '../auth'; import { customObjectDropdown, customObjectPropertiesDropdown } from '../common/props'; import { FilterOperatorEnum } from '../common/types'; import { MAX_SEARCH_PAGE_SIZE } from '../common/constants'; +import { customObjectSearchOutputSchema } from '../output-schemas'; export const findCustomObjectAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const findCustomObjectAction = createAction({ description: 'Finds a custom object by searching.', audience: 'both', aiMetadata: { description: 'Search records of a selected HubSpot custom object type by one or two property/value pairs (matched with equality) and return the matches. Read-only and repeatable. Requires choosing the custom object type; use Create Custom Object to add a new record.', idempotent: true }, + outputSchema: customObjectSearchOutputSchema, props: { customObjectType: customObjectDropdown, firstSearchPropertyName: customObjectPropertiesDropdown( diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-deal.ts b/packages/pieces/community/hubspot/src/lib/actions/find-deal.ts index 404c15aefb01..e99efc636b1f 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-deal.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-deal.ts @@ -5,6 +5,7 @@ import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { FilterOperatorEnum } from '../common/types'; import { Client } from '@hubspot/api-client'; +import { dealSearchOutputSchema } from '../output-schemas'; export const findDealAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const findDealAction = createAction({ description: 'Finds a deal by searching.', audience: 'both', aiMetadata: { description: 'Search HubSpot deals by one or two property/value pairs (matched with equality) and return the matching deals. Read-only and repeatable. Use this to look up an existing deal before updating or associating it; pick a create action instead when no matching deal should exist.', idempotent: true }, + outputSchema: dealSearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-line-item.ts b/packages/pieces/community/hubspot/src/lib/actions/find-line-item.ts index c7353613b363..b9d9b9073bee 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-line-item.ts @@ -5,6 +5,7 @@ import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { FilterOperatorEnum } from '../common/types'; +import { lineItemSearchOutputSchema } from '../output-schemas'; export const findLineItemAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const findLineItemAction = createAction({ description: 'Finds a line item by searching.', audience: 'both', aiMetadata: { description: 'Search HubSpot line items by one or two property/value pairs (matched with equality) and return the matches. Read-only and repeatable. Use this to locate an existing line item before updating it.', idempotent: true }, + outputSchema: lineItemSearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-product.ts b/packages/pieces/community/hubspot/src/lib/actions/find-product.ts index c5e8254c04ea..fa25a354b624 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-product.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-product.ts @@ -7,6 +7,7 @@ import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { Client } from '@hubspot/api-client'; import { FilterOperatorEnum } from '../common/types'; +import { productSearchOutputSchema } from '../output-schemas'; export const findProductAction = createAction({ auth: hubspotAuth, @@ -16,6 +17,7 @@ export const findProductAction = createAction({ description: 'Finds a product by searching.', audience: 'both', aiMetadata: { description: 'Search the HubSpot product library by one or two property/value pairs (matched with equality) and return the matching products. Read-only and repeatable. Use Get Product instead when you already have the product ID.', idempotent: true }, + outputSchema: productSearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/find-ticket.ts b/packages/pieces/community/hubspot/src/lib/actions/find-ticket.ts index 9281fdc6e65e..e14094796374 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/find-ticket.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/find-ticket.ts @@ -5,6 +5,7 @@ import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE } from '../common/constants'; import { FilterOperatorEnum } from '../common/types'; +import { ticketSearchOutputSchema } from '../output-schemas'; export const findTicketAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const findTicketAction = createAction({ description: 'Finds a ticket by searching.', audience: 'both', aiMetadata: { description: 'Searches support tickets via the HubSpot CRM search API, matching on one or two property name/value pairs (exact match, combined as AND), and returns matching tickets. Use to locate a ticket by subject or another property before reading or updating it; prefer Get Ticket when you already have the ticket ID. Read-only and idempotent.', idempotent: true }, + outputSchema: ticketSearchOutputSchema, props: { firstSearchPropertyName: standardObjectPropertiesDropdown( { diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-company.ts b/packages/pieces/community/hubspot/src/lib/actions/get-company.ts index 07246e639207..c2d07f90a2b0 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-company.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-company.ts @@ -7,6 +7,7 @@ import { import { OBJECT_TYPE } from '../common/constants'; import { Client } from '@hubspot/api-client'; import { MarkdownVariant } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getCompanyAction = createAction({ auth: hubspotAuth, @@ -16,6 +17,7 @@ export const getCompanyAction = createAction({ description: 'Gets a company.', audience: 'both', aiMetadata: { description: 'Fetches a single company by its HubSpot company ID, returning default and any requested additional properties. Use when you already have the company ID; use Find Company to look one up by domain or another property first. Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { companyId: Property.ShortText({ displayName: 'Company ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-contact.ts b/packages/pieces/community/hubspot/src/lib/actions/get-contact.ts index 141e8d7401eb..ea19434d4b14 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-contact.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getContactAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const getContactAction = createAction({ description: 'Gets a contact.', audience: 'both', aiMetadata: { description: 'Fetches a single contact by its HubSpot contact ID, returning default and any requested additional properties. Use when you already have the contact ID; to look one up by email instead, use a search-based action. Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { contactId: Property.ShortText({ displayName: 'Contact ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-custom-object.ts b/packages/pieces/community/hubspot/src/lib/actions/get-custom-object.ts index f020706e85e3..722e0a307357 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-custom-object.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-custom-object.ts @@ -4,6 +4,7 @@ import { Client } from '@hubspot/api-client'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { customObjectDropdown, customObjectPropertiesDropdown } from '../common/props'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getCustomObjectAction = createAction({ auth: hubspotAuth, @@ -13,6 +14,7 @@ export const getCustomObjectAction = createAction({ description: 'Gets a custom object.', audience: 'both', aiMetadata: { description: 'Fetches a single custom-object record by its ID for a chosen custom object type, returning the requested properties. Use when you already have the record ID and the custom object type; for standard CRM objects use the dedicated Get Contact / Deal / Company / Ticket actions instead. Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { customObjectType: customObjectDropdown, customObjectId: Property.ShortText({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-deal.ts b/packages/pieces/community/hubspot/src/lib/actions/get-deal.ts index 61cb5e396dc0..7407303b82ec 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-deal.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-deal.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getDealAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const getDealAction = createAction({ description: 'Gets a deal.', audience: 'both', aiMetadata: { description: 'Fetches a single deal by its HubSpot deal ID, returning default and any requested additional properties such as amount, stage, and close date. Use when you already have the deal ID. Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { dealId: Property.ShortText({ displayName: 'Deal ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-line-item.ts b/packages/pieces/community/hubspot/src/lib/actions/get-line-item.ts index 234b25324a5e..c591c88f5fc9 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-line-item.ts @@ -4,6 +4,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getLineItemAction = createAction({ auth: hubspotAuth, @@ -13,6 +14,7 @@ export const getLineItemAction = createAction({ description: 'Gets a line item.', audience: 'both', aiMetadata: { description: 'Fetches a single line item by its HubSpot line item ID, returning default and any requested additional properties. Use when you already have the line item ID and need its details (price, quantity, product). Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { lineItemId: Property.ShortText({ displayName: 'Line Item ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-email.ts b/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-email.ts index 623c3f42a232..44a9b7c34e59 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-email.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-email.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { Client } from '@hubspot/api-client'; +import { getOwnerByEmailOutputSchema } from '../output-schemas'; export const getOwnerByEmailAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const getOwnerByEmailAction = createAction({ 'Look up a single HubSpot CRM owner (user) by their email address; use to resolve an email into an owner identity before assigning records to that owner. Read-only and repeatable. Fails if no owner matches the given email.', idempotent: true, }, + outputSchema: getOwnerByEmailOutputSchema, props: { email: Property.ShortText({ displayName: 'Owner Email', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-id.ts b/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-id.ts index decec1b08827..37042c3dfdde 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-id.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-owner-by-id.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { Client } from '@hubspot/api-client'; +import { getOwnerByIdOutputSchema } from '../output-schemas'; export const getOwnerByIdAction = createAction({ auth: hubspotAuth, @@ -10,6 +11,7 @@ export const getOwnerByIdAction = createAction({ description: 'Gets an existing owner by ID.', audience: 'both', aiMetadata: { description: 'Fetches a single HubSpot owner (user) by their numeric owner ID, returning their identity details such as name and email. Use to resolve an owner ID into a person before assigning records or displaying who owns a deal, contact, or ticket. Read-only and idempotent.', idempotent: true }, + outputSchema: getOwnerByIdOutputSchema, props: { ownerId: Property.ShortText({ displayName: 'Owner ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-page.ts b/packages/pieces/community/hubspot/src/lib/actions/get-page.ts index bff6a64c760e..4ffc8d53eb64 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-page.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-page.ts @@ -2,6 +2,7 @@ import { hubspotAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; import { pageType } from '../common/props'; +import { pageOutputSchema } from '../output-schemas'; export const getPageAction = createAction({ auth: hubspotAuth, @@ -15,6 +16,7 @@ export const getPageAction = createAction({ 'Fetch the details of a single HubSpot CMS page by its ID. Use when you already have a page ID and need its current data; the page type input selects whether to read a site page or a landing page. Read-only and repeatable.', idempotent: true, }, + outputSchema: pageOutputSchema, props: { pageType: pageType, pageId: Property.ShortText({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-pipeline-stage-details.ts b/packages/pieces/community/hubspot/src/lib/actions/get-pipeline-stage-details.ts index 2d67326b5a72..a7c733420952 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-pipeline-stage-details.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-pipeline-stage-details.ts @@ -1,6 +1,7 @@ import { hubspotAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { pipelineStageDetailsOutputSchema } from '../output-schemas'; export const getPipelineStageDetailsAction = createAction({ auth: hubspotAuth, @@ -10,6 +11,7 @@ export const getPipelineStageDetailsAction = createAction({ description: 'Finds and retrieves CRM object pipeline stage details.', audience: 'both', aiMetadata: { description: 'Retrieves the configuration of a single pipeline stage (label, order, metadata) for a ticket or deal pipeline, given the object type, pipeline ID, and stage ID. Use to resolve or validate a stage ID before setting a deal or ticket stage. Read-only and idempotent.', idempotent: true }, + outputSchema: pipelineStageDetailsOutputSchema, props: { objectType: Property.StaticDropdown({ displayName: 'Object Type', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-product.ts b/packages/pieces/community/hubspot/src/lib/actions/get-product.ts index 6b146b48d204..a84545c5d9b6 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-product.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-product.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getProductAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const getProductAction = createAction({ description: 'Gets a product.', audience: 'both', aiMetadata: { description: 'Fetch a single HubSpot product by its product ID, returning its default and any requested additional properties. Read-only and repeatable. Use Find Product when you only know property values rather than the ID.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { productId: Property.ShortText({ displayName: 'Product ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/get-ticket.ts b/packages/pieces/community/hubspot/src/lib/actions/get-ticket.ts index af681c817ec5..4b07632684d0 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/get-ticket.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/get-ticket.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; import { hubspotAuth } from '../auth'; +import { crmObjectOutputSchema } from '../output-schemas'; export const getTicketAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const getTicketAction = createAction({ description: 'Gets a ticket.', audience: 'both', aiMetadata: { description: 'Fetches a single support ticket by its HubSpot ticket ID, returning default and any requested additional properties. Use when you already have the ticket ID; use Find Ticket to look one up by another property first. Read-only and idempotent.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { ticketId: Property.ShortText({ displayName: 'Ticket ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/remove-associations.ts b/packages/pieces/community/hubspot/src/lib/actions/remove-associations.ts index ccd599f16045..661675967ffb 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/remove-associations.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/remove-associations.ts @@ -9,6 +9,7 @@ import { OBJECT_TYPE } from '../common/constants'; import { Client } from '@hubspot/api-client'; import { AssociationSpecAssociationCategoryEnum } from '../common/types'; import { chunk } from '@activepieces/pieces-framework'; +import { removeAssociationsOutputSchema } from '../output-schemas'; export const removeAssociationsAction = createAction({ auth: hubspotAuth, @@ -18,6 +19,7 @@ export const removeAssociationsAction = createAction({ description: 'Removes associations between objects', audience: 'both', aiMetadata: { description: 'Remove the labeled association of a specific type between one source HubSpot object and one or more target objects, batching the targets. Removing an already-absent association is harmless, so it is idempotent on the end state. Use Create Associations to add links.', idempotent: true }, + outputSchema: removeAssociationsOutputSchema, props: { fromObjectId: Property.ShortText({ displayName: 'From Object ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-company.ts b/packages/pieces/community/hubspot/src/lib/actions/update-company.ts index 40cac9d63e75..572675447377 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-company.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-company.ts @@ -9,6 +9,7 @@ import { import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateCompanyAction = createAction({ auth: hubspotAuth, @@ -18,6 +19,7 @@ export const updateCompanyAction = createAction({ description: 'Updates a company in Hubspot.', audience: 'both', aiMetadata: { description: 'Update properties on an existing HubSpot company identified by Company ID; only the supplied fields are changed. Applying the same field values repeatedly leaves the record in the same state, so it is idempotent. Use Create Company to add a new record, or a find action to obtain the ID first.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { companyId: Property.ShortText({ displayName: 'Company ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-contact.ts b/packages/pieces/community/hubspot/src/lib/actions/update-contact.ts index afcc82b0b331..c2f5ca193046 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-contact.ts @@ -9,6 +9,7 @@ import { import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateContactAction = createAction({ auth: hubspotAuth, @@ -18,6 +19,7 @@ export const updateContactAction = createAction({ description: 'Updates a contact in Hubspot.', audience: 'both', aiMetadata: { description: 'Update properties on an existing HubSpot contact identified by Contact ID; only the supplied fields are changed. Applying the same values repeatedly is idempotent. Use a find action to resolve the contact ID first, or a create action to add a new contact.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { contactId: Property.ShortText({ displayName: 'Contact ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-custom-object.ts b/packages/pieces/community/hubspot/src/lib/actions/update-custom-object.ts index 188468bf984b..b7911e019864 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-custom-object.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-custom-object.ts @@ -8,6 +8,7 @@ import { } from '../common/props'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateCustomObjectAction = createAction({ auth: hubspotAuth, @@ -17,6 +18,7 @@ export const updateCustomObjectAction = createAction({ description: 'Updates a custom object in Hubspot.', audience: 'both', aiMetadata: { description: 'Updates properties on an existing custom-object record identified by its custom object type and record ID, then returns the refreshed record. Use to modify a known custom-object record; for standard CRM objects use the dedicated update actions instead. Idempotent: applying the same property values converges to the same record state.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { customObjectType: customObjectDropdown, customObjectId: Property.ShortText({ diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-deal.ts b/packages/pieces/community/hubspot/src/lib/actions/update-deal.ts index a3118412b06e..1be47f83e6e4 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-deal.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-deal.ts @@ -16,6 +16,7 @@ import { } from '../common/props'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateDealAction = createAction({ auth: hubspotAuth, @@ -25,6 +26,7 @@ export const updateDealAction = createAction({ description: 'Updates a deal in HubSpot.', audience: 'both', aiMetadata: { description: 'Updates properties on an existing deal identified by its deal ID, such as name, pipeline, stage, or custom fields, then returns the refreshed deal. Use to modify a known deal; use Create Deal to make a new one. Idempotent: applying the same property values converges to the same deal state.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { dealId: Property.ShortText({ displayName: 'Deal ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-line-item.ts b/packages/pieces/community/hubspot/src/lib/actions/update-line-item.ts index 7aaf55a19f64..6c8a01b83092 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-line-item.ts @@ -10,6 +10,7 @@ import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateLineItemAction = createAction({ auth: hubspotAuth, @@ -19,6 +20,7 @@ export const updateLineItemAction = createAction({ description: 'Updates a line item in Hubspot.', audience: 'both', aiMetadata: { description: 'Update properties (product, quantity, price, etc.) on an existing HubSpot line item identified by Line Item ID; only supplied fields change. Applying the same values repeatedly is idempotent. Use a find action to obtain the line item ID first.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { lineItemId: Property.ShortText({ displayName: 'Line Item ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-product.ts b/packages/pieces/community/hubspot/src/lib/actions/update-product.ts index 2b0b4642e2eb..b8d43c900457 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-product.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-product.ts @@ -9,6 +9,7 @@ import { import { OBJECT_TYPE } from '../common/constants'; import { MarkdownVariant } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateProductAction = createAction({ auth: hubspotAuth, @@ -18,6 +19,7 @@ export const updateProductAction = createAction({ description: 'Updates a product in Hubspot.', audience: 'both', aiMetadata: { description: 'Updates properties on an existing product identified by its product ID, such as name, price, description, or tax, then returns the refreshed product. Use to modify a known product in the product library. Idempotent: applying the same property values converges to the same product state.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { productId:Property.ShortText({ displayName:'Product ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/update-ticket.ts b/packages/pieces/community/hubspot/src/lib/actions/update-ticket.ts index f38dc5f9184a..a96dd2731b1b 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/update-ticket.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/update-ticket.ts @@ -5,6 +5,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { hubspotAuth } from '../auth'; import { getDefaultPropertiesForObject, pipelineDropdown, pipelineStageDropdown, standardObjectDynamicProperties, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; export const updateTicketAction = createAction({ auth: hubspotAuth, @@ -14,6 +15,7 @@ export const updateTicketAction = createAction({ description: 'Updates a ticket in HubSpot.', audience: 'both', aiMetadata: { description: 'Updates properties on an existing support ticket identified by its ticket ID, such as subject, pipeline, stage, or custom fields, then returns the refreshed ticket. Use to modify a known ticket. Idempotent: applying the same property values converges to the same ticket state.', idempotent: true }, + outputSchema: crmObjectOutputSchema, props: { ticketId: Property.ShortText({ displayName: 'Ticket ID', diff --git a/packages/pieces/community/hubspot/src/lib/actions/upload-file.ts b/packages/pieces/community/hubspot/src/lib/actions/upload-file.ts index 1bcf2feeb398..b6713f7c3c9e 100644 --- a/packages/pieces/community/hubspot/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/hubspot/src/lib/actions/upload-file.ts @@ -6,6 +6,7 @@ import { Property, } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; +import { uploadFileOutputSchema } from '../output-schemas'; export const uploadFileAction = createAction({ auth: hubspotAuth, @@ -15,6 +16,7 @@ export const uploadFileAction = createAction({ description: 'Uploads a file to HubSpot File Manager.', audience: 'both', aiMetadata: { description: 'Upload a file into a chosen folder in the HubSpot File Manager with a given name and access level. Each call uploads a new file rather than replacing an existing one, so it is not idempotent.', idempotent: false }, + outputSchema: uploadFileOutputSchema, props: { folderId: Property.Dropdown({ auth: hubspotAuth, diff --git a/packages/pieces/community/hubspot/src/lib/output-schemas.ts b/packages/pieces/community/hubspot/src/lib/output-schemas.ts new file mode 100644 index 000000000000..63b92a1f7aee --- /dev/null +++ b/packages/pieces/community/hubspot/src/lib/output-schemas.ts @@ -0,0 +1,307 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const crmObjectFields: OutputSchemaField[] = [ + { key: 'id', label: 'Record ID' }, + { + key: 'properties', + label: 'Properties', + dynamicKey: true, + description: 'Requested properties, keyed by their HubSpot internal names.', + }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'archivedAt', label: 'Archived At', format: 'datetime' }, +]; + +const pagingFields: OutputSchemaField[] = [ + { + key: 'next', + label: 'Next Page', + children: [ + { key: 'after', label: 'After Cursor' }, + { key: 'link', label: 'Link', format: 'url' }, + ], + }, +]; + +function crmSearchFields({ + itemsLabel, + labelKey, +}: { + itemsLabel: string; + labelKey: string; +}): OutputSchemaField[] { + return [ + { key: 'total', label: 'Total Matches', format: 'number' }, + { key: 'results', label: itemsLabel, labelKey, listItems: crmObjectFields }, + { key: 'paging', label: 'Paging', children: pagingFields }, + ]; +} + +export const crmObjectOutputSchema: OutputSchema = { fields: crmObjectFields }; + +export const contactSearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Contacts', labelKey: 'properties.email' }), +}; + +export const companySearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Companies', labelKey: 'properties.name' }), +}; + +export const dealSearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Deals', labelKey: 'properties.dealname' }), +}; + +export const ticketSearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Tickets', labelKey: 'properties.subject' }), +}; + +export const productSearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Products', labelKey: 'properties.name' }), +}; + +export const lineItemSearchOutputSchema: OutputSchema = { + fields: crmSearchFields({ itemsLabel: 'Line Items', labelKey: 'properties.name' }), +}; + +const ownerFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Owner ID' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'type', label: 'Type' }, + { key: 'userId', label: 'User ID' }, + { key: 'userIdIncludingInactive', label: 'User ID (Including Inactive)' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, +]; + +export const getOwnerByIdOutputSchema: OutputSchema = { fields: ownerFields }; + +export const getOwnerByEmailOutputSchema: OutputSchema = { + fields: [{ key: 'results', label: 'Owners', labelKey: 'email', listItems: ownerFields }], +}; + +export const pipelineStageDetailsOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Stage ID' }, + { key: 'label', label: 'Label' }, + { key: 'displayOrder', label: 'Display Order', format: 'number' }, + { key: 'writePermissions', label: 'Write Permissions' }, + { + key: 'metadata', + label: 'Metadata', + dynamicKey: true, + description: 'Stage settings, keyed by name; varies between deal and ticket pipelines.', + }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + ], +}; + +const pageFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Page ID' }, + { key: 'name', label: 'Internal Page Name' }, + { key: 'htmlTitle', label: 'Page Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'domain', label: 'Domain' }, + { key: 'state', label: 'State' }, + { key: 'published', label: 'Published', format: 'boolean' }, + { key: 'templatePath', label: 'Template Path' }, + { key: 'language', label: 'Language' }, + { key: 'authorName', label: 'Author Name' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'archivedAt', label: 'Archived At', format: 'datetime' }, +]; + +export const pageOutputSchema: OutputSchema = { fields: pageFields }; + +const associationBatchResultFields: OutputSchema['fields'] = [ + { key: 'fromObjectId', label: 'From Object ID' }, + { key: 'fromObjectTypeId', label: 'From Object Type ID' }, + { key: 'toObjectId', label: 'To Object ID' }, + { key: 'toObjectTypeId', label: 'To Object Type ID' }, + { key: 'labels', label: 'Labels' }, +]; + +export const createAssociationsOutputSchema: OutputSchema = { + fields: [ + { key: 'totalAssociations', label: 'Total Associations', format: 'number' }, + { key: 'batchCount', label: 'Batch Count', format: 'number' }, + { + key: 'responses', + label: 'Batch Responses', + listItems: [ + { key: 'status', label: 'Status' }, + { key: 'startedAt', label: 'Started At', format: 'datetime' }, + { key: 'completedAt', label: 'Completed At', format: 'datetime' }, + { key: 'results', label: 'Results', listItems: associationBatchResultFields }, + ], + }, + ], +}; + +export const removeAssociationsOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'totalAssociations', label: 'Total Associations', format: 'number' }, + { key: 'batchCount', label: 'Batch Count', format: 'number' }, + { + key: 'responses', + label: 'Batch Responses', + description: 'One entry per batch; HubSpot returns no content per batch on success.', + }, + ], +}; + +export const findAssociationsOutputSchema: OutputSchema = { + itemLabel: 'To Object {toObjectId}', + fields: [ + { + key: 'associations', + label: 'Associations', + value: '', + listItems: [ + { key: 'toObjectId', label: 'To Object ID' }, + { + key: 'associationTypes', + label: 'Association Types', + listItems: [ + { key: 'typeId', label: 'Type ID', format: 'number' }, + { key: 'label', label: 'Label' }, + { key: 'category', label: 'Category' }, + ], + }, + ], + }, + ], +}; + +export const newContactInListTriggerOutputSchema: OutputSchema = { + fields: [...crmObjectFields, { key: 'membershipTimestamp', label: 'Added to List At', format: 'datetime' }], +}; + +export const newFormSubmissionTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'conversionId', label: 'Conversion ID' }, + { key: 'submittedAt', label: 'Submitted At', format: 'datetime' }, + { + key: 'values', + label: 'Submitted Values', + dynamicKey: true, + description: 'Field values, keyed by the form field label.', + }, + { key: 'pageUrl', label: 'Page URL', format: 'url' }, + ], +}; + +export const newEmailSubscriptionsTimelineTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + { key: 'recipient', label: 'Recipient', format: 'email' }, + { key: 'normalizedEmailId', label: 'Normalized Email ID' }, + { key: 'portalId', label: 'Portal ID', format: 'number' }, + { + key: 'changes', + label: 'Changes', + listItems: [ + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + { key: 'subscriptionId', label: 'Subscription ID', format: 'number' }, + { key: 'changeType', label: 'Change Type' }, + { key: 'change', label: 'Change' }, + { key: 'source', label: 'Source' }, + { key: 'portalId', label: 'Portal ID', format: 'number' }, + { + key: 'causedByEvent', + label: 'Caused By Event', + children: [ + { key: 'id', label: 'Event ID' }, + { key: 'created', label: 'Created At', format: 'datetime' }, + ], + }, + ], + }, + ], +}; + +export const createBlogPostOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Post ID' }, + { key: 'name', label: 'Name' }, + { key: 'title', label: 'Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'absolute_url', label: 'Absolute URL', format: 'url' }, + { key: 'state', label: 'State' }, + { key: 'currently_published', label: 'Currently Published', format: 'boolean' }, + { key: 'content_group_id', label: 'Blog ID' }, + { key: 'blog_author_id', label: 'Author ID' }, + { key: 'author_name', label: 'Author Name' }, + { key: 'meta_description', label: 'Meta Description' }, + { key: 'post_body', label: 'Body', format: 'html' }, + { key: 'featured_image', label: 'Featured Image', format: 'image' }, + { key: 'created', label: 'Created At', format: 'datetime' }, + { key: 'updated', label: 'Updated At', format: 'datetime' }, + { key: 'publish_date', label: 'Publish Date', format: 'datetime' }, + ], +}; + +export const newBlogArticleTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Post ID' }, + { key: 'name', label: 'Name' }, + { key: 'htmlTitle', label: 'Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'state', label: 'State' }, + { key: 'currentState', label: 'Current State' }, + { key: 'currentlyPublished', label: 'Currently Published', format: 'boolean' }, + { key: 'contentGroupId', label: 'Blog ID' }, + { key: 'blogAuthorId', label: 'Author ID' }, + { key: 'authorName', label: 'Author Name' }, + { key: 'metaDescription', label: 'Meta Description' }, + { key: 'postBody', label: 'Body', format: 'html' }, + { key: 'featuredImage', label: 'Featured Image', format: 'image' }, + { key: 'created', label: 'Created At', format: 'datetime' }, + { key: 'updated', label: 'Updated At', format: 'datetime' }, + { key: 'publishDate', label: 'Publish Date', format: 'datetime' }, + { key: 'publishedAt', label: 'Published At', format: 'datetime' }, + ], +}; + +export const customObjectSearchOutputSchema: OutputSchema = { + fields: [ + { key: 'total', label: 'Total Matches', format: 'number' }, + { + key: 'results', + label: 'Records', + listItems: crmObjectFields, + description: 'No labelKey is set: which property best labels a record varies per custom object type.', + }, + { key: 'paging', label: 'Paging', children: pagingFields }, + ], +}; + +export const uploadFileOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'File ID' }, + { key: 'name', label: 'Name' }, + { key: 'path', label: 'Path' }, + { key: 'parentFolderId', label: 'Parent Folder ID' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'type', label: 'Type' }, + { key: 'extension', label: 'Extension' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'defaultHostingUrl', label: 'Default Hosting URL', format: 'url' }, + { key: 'access', label: 'Access Level' }, + { key: 'isUsableInContent', label: 'Usable In Content', format: 'boolean' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + ], +}; diff --git a/packages/pieces/community/hubspot/src/lib/triggers/deal-stage-updated.ts b/packages/pieces/community/hubspot/src/lib/triggers/deal-stage-updated.ts index d46e6bebc3d7..0d5e602e9e8b 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/deal-stage-updated.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/deal-stage-updated.ts @@ -30,6 +30,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -129,6 +130,7 @@ export const dealStageUpdatedTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/email-subscriptions-timeline.ts b/packages/pieces/community/hubspot/src/lib/triggers/email-subscriptions-timeline.ts index 9d78e1b7916a..b8c24514ebf8 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/email-subscriptions-timeline.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/email-subscriptions-timeline.ts @@ -14,6 +14,7 @@ import { PiecePropValueSchema, TriggerStrategy, } from '@activepieces/pieces-framework'; +import { newEmailSubscriptionsTimelineTriggerOutputSchema } from '../output-schemas'; type SubscriptionTimeLineResponse = { hasMore: boolean; @@ -65,6 +66,7 @@ export const newEmailSubscriptionsTimelineTrigger = createTrigger({ description: 'Fires when a new email-subscription timeline event is recorded for the HubSpot portal. Each event represents one subscription change (such as a bounce, unsubscribe, or opt-in) for a recipient, including the change type, source, and the underlying event that caused it. Polls the portal-wide email subscription timeline by timestamp.', }, + outputSchema: newEmailSubscriptionsTimelineTriggerOutputSchema, type: TriggerStrategy.POLLING, props: {}, async onEnable(context) { diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-blog-article.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-blog-article.ts index b8ec5703d697..129f271d71d4 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-blog-article.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-blog-article.ts @@ -15,6 +15,7 @@ import { TriggerStrategy, } from '@activepieces/pieces-framework'; import dayjs from 'dayjs'; +import { newBlogArticleTriggerOutputSchema } from '../output-schemas'; type Props = { articleState: string; @@ -91,6 +92,7 @@ export const newBlogArticleTrigger = createTrigger({ description: 'Fires when a blog post is added to your HubSpot COS (CMS) blog. The configured article state (Published only, Draft only, or Both) determines which posts qualify; published posts are tracked by publish date and drafts by creation date. Each event represents one blog post with its full CMS metadata.', }, + outputSchema: newBlogArticleTriggerOutputSchema, type: TriggerStrategy.POLLING, props: { articleState: Property.StaticDropdown({ diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-company-property-change.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-company-property-change.ts index b88f38dafe60..0819917d5f9a 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-company-property-change.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-company-property-change.ts @@ -13,6 +13,7 @@ import { chunk } from '@activepieces/pieces-framework'; import { Client } from '@hubspot/api-client'; import dayjs from 'dayjs'; import { FilterOperatorEnum } from '../common/types'; +import { crmObjectOutputSchema } from '../output-schemas'; type Props = { propertyName?: string | string[]; @@ -143,6 +144,7 @@ export const newCompanyPropertyChangeTrigger = createTrigger({ true, ), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-company.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-company.ts index 6518b941b4d1..30e1a82d8e50 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-company.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-company.ts @@ -16,6 +16,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -94,6 +95,7 @@ export const newCompanyTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-contact-in-list.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-contact-in-list.ts index 1c299b3db34c..51c126b43a21 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-contact-in-list.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-contact-in-list.ts @@ -12,6 +12,7 @@ import { MarkdownVariant } from '@activepieces/pieces-framework'; import { getDefaultPropertiesForObject, standardObjectPropertiesDropdown } from '../common/props'; import { OBJECT_TYPE } from '../common/constants'; import dayjs from 'dayjs'; +import { newContactInListTriggerOutputSchema } from '../output-schemas'; type Props = { listId: string; @@ -95,6 +96,7 @@ export const newContactInListTrigger = createTrigger({ description: 'Fires when a contact is added to the selected HubSpot contact list. Each event represents one contact whose membership was added since the last poll, enriched with the contact record properties (name, email, etc.) plus the timestamp it joined the list. Tracked by list-membership date.', }, + outputSchema: newContactInListTriggerOutputSchema, type: TriggerStrategy.POLLING, props: { listId: Property.Dropdown({ diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-contact-property-change.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-contact-property-change.ts index 65d62c11fed4..c6159c23362c 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-contact-property-change.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-contact-property-change.ts @@ -19,6 +19,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -145,6 +146,7 @@ export const newContactPropertyChangeTrigger = createTrigger({ true, ), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-contact.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-contact.ts index 2478622e07a5..9bc9a2bd580a 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-contact.ts @@ -10,6 +10,7 @@ import { OBJECT_TYPE, MAX_SEARCH_PAGE_SIZE, MAX_SEARCH_TOTAL_RESULTS } from '../ import { hubspotAuth } from '../auth'; import { Client } from '@hubspot/api-client'; import { FilterOperatorEnum } from '../common/types'; +import { crmObjectOutputSchema } from '../output-schemas'; type Props = { additionalPropertiesToRetrieve?: string | string[]; @@ -96,6 +97,7 @@ export const newContactTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object-property-change.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object-property-change.ts index c4f42db6a11e..7525e3f46f63 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object-property-change.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object-property-change.ts @@ -17,6 +17,7 @@ import { Client } from '@hubspot/api-client'; import dayjs from 'dayjs'; import { FilterOperatorEnum } from '../common/types'; import { MAX_SEARCH_PAGE_SIZE, MAX_SEARCH_TOTAL_RESULTS } from '../common/constants'; +import { crmObjectOutputSchema } from '../output-schemas'; type Props = { customObjectType?: string; @@ -143,6 +144,7 @@ export const newCustomObjectPropertyChangeTrigger = createTrigger({ customObjectType: customObjectDropdown, propertyName: customObjectPropertiesDropdown('Property Name', true, true), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object.ts index 4875b890ae34..c28efaefced3 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-custom-object.ts @@ -14,6 +14,7 @@ import { Client } from '@hubspot/api-client'; import { FilterOperatorEnum } from '../common/types'; import { MAX_SEARCH_PAGE_SIZE, MAX_SEARCH_TOTAL_RESULTS } from '../common/constants'; import dayjs from 'dayjs'; +import { crmObjectOutputSchema } from '../output-schemas'; type Props = { customObjectType?: string; @@ -112,6 +113,7 @@ export const newCustomObjectTrigger = createTrigger({ false, ), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-deal-property-change.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-deal-property-change.ts index 94f3b7cbf1be..85cf21a3f8e6 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-deal-property-change.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-deal-property-change.ts @@ -19,6 +19,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -144,6 +145,7 @@ export const newDealPropertyChangeTrigger = createTrigger({ true, ), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-deal.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-deal.ts index 60a451ae1c59..18ee46396759 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-deal.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-deal.ts @@ -16,6 +16,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newDealTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-form-submission.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-form-submission.ts index 8eb91c0236a0..61848239b65a 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-form-submission.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-form-submission.ts @@ -15,6 +15,7 @@ import { Property, } from '@activepieces/pieces-framework'; import { formDropdown } from '../common/props'; +import { newFormSubmissionTriggerOutputSchema } from '../output-schemas'; type Props = { formId: string; @@ -131,6 +132,7 @@ export const newFormSubmissionTrigger = createTrigger({ description: 'Fires when the selected HubSpot form receives a submission. Each event represents one submission, with field values mapped to their human-readable form labels plus metadata such as submission timestamp, conversion ID, and page URL. Polls the form-integrations submissions API.', }, + outputSchema: newFormSubmissionTriggerOutputSchema, type: TriggerStrategy.POLLING, props: { formId: formDropdown, diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-line-item.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-line-item.ts index c38fe36b6d50..af8a02a12b12 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-line-item.ts @@ -19,6 +19,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -100,6 +101,7 @@ export const newLineItemTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-company.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-company.ts index d9b37184d3d1..185be052de77 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-company.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-company.ts @@ -15,6 +15,7 @@ import { FilterOperatorEnum } from '../common/types'; import dayjs from 'dayjs'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling,{ additionalPropertiesToRetrieve?: string[] | string }> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newOrUpdatedCompanyTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-contact.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-contact.ts index ce7683bb7248..1e3c6b8eaa41 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-contact.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-contact.ts @@ -15,6 +15,7 @@ import { FilterOperatorEnum } from '../common/types'; import dayjs from 'dayjs'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling,{ additionalPropertiesToRetrieve?: string[] | string }> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newOrUpdatedContactTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-line-item.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-line-item.ts index 34c46142c6c4..3aa2744d7d05 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-line-item.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-line-item.ts @@ -15,6 +15,7 @@ import { FilterOperatorEnum } from '../common/types'; import dayjs from 'dayjs'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling,{ additionalPropertiesToRetrieve?: string[] | string }> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newOrUpdatedLineItemTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-product.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-product.ts index 1d189def4419..fe17f4426b93 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-product.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-or-updated-product.ts @@ -15,6 +15,7 @@ import { FilterOperatorEnum } from '../common/types'; import dayjs from 'dayjs'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling,{ additionalPropertiesToRetrieve?: string[] | string }> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newOrUpdatedProductTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-product.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-product.ts index b82f5458c7ba..10ed94914c74 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-product.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-product.ts @@ -19,6 +19,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -100,6 +101,7 @@ export const newProductTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-task.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-task.ts index acf7eca2ce8e..3da18a72929a 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-task.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-task.ts @@ -16,6 +16,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newTaskTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-ticket-property-change.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-ticket-property-change.ts index b5fbcb6274ea..00f082c0558a 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-ticket-property-change.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-ticket-property-change.ts @@ -19,6 +19,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -144,6 +145,7 @@ export const newTicketPropertyChangeTrigger = createTrigger({ true, ), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/hubspot/src/lib/triggers/new-ticket.ts b/packages/pieces/community/hubspot/src/lib/triggers/new-ticket.ts index 3ee850ec2fc3..91bed067625d 100644 --- a/packages/pieces/community/hubspot/src/lib/triggers/new-ticket.ts +++ b/packages/pieces/community/hubspot/src/lib/triggers/new-ticket.ts @@ -16,6 +16,7 @@ type Props = { }; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { crmObjectOutputSchema } from '../output-schemas'; const polling: Polling, Props> = { strategy: DedupeStrategy.TIMEBASED, async items({ auth, propsValue, lastFetchEpochMS }) { @@ -96,6 +97,7 @@ export const newTicketTrigger = createTrigger({ required: false, }), }, + outputSchema: crmObjectOutputSchema, type: TriggerStrategy.POLLING, async onEnable(context) { await pollingHelper.onEnable(polling, context); diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts index 464160721292..15a8397f26ec 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { messageIdDropdown } from '../common/props'; +import { messageActionOutputSchema } from '../output-schemas'; export const addLabelToEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -11,6 +12,7 @@ export const addLabelToEmailAction = createAction({ description: 'Adds a category (label) to an email message.', audience: 'both', aiMetadata: { description: 'Adds one or more Outlook categories (labels) to a specific message, merging them with any categories already present. Use this to tag or classify an email. Idempotent: re-adding the same categories leaves the message unchanged since duplicates are de-duplicated.', idempotent: true }, + outputSchema: messageActionOutputSchema, props: { messageId: messageIdDropdown({ displayName: 'Email', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts index 02e83c9688ee..4fa22b2a2d67 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts @@ -2,6 +2,7 @@ import { ApFile, createAction, Property } from '@activepieces/pieces-framework'; import { BodyType, Message } from '@microsoft/microsoft-graph-types'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; +import { draftMessageActionOutputSchema } from '../output-schemas'; export const createDraftEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -11,6 +12,7 @@ export const createDraftEmailAction = createAction({ description: 'Creates a draft email message.', audience: 'both', aiMetadata: { description: 'Creates a new unsent draft email in the Outlook mailbox with recipients, subject, body, and optional attachments. Use this to stage a message for later review or sending (pair with Send Draft Email). Not idempotent: each call creates a separate draft.', idempotent: false }, + outputSchema: draftMessageActionOutputSchema, props: { recipients: Property.Array({ displayName: 'To Email(s)', @@ -64,9 +66,9 @@ export const createDraftEmailAction = createAction({ }, async run(context) { const recipients = context.propsValue.recipients as string[]; - const ccRecipients = context.propsValue.ccRecipients as string[]; - const bccRecipients = context.propsValue.bccRecipients as string[]; - const attachments = context.propsValue.attachments as Array<{ file: ApFile; fileName: string }>; + const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[]; + const bccRecipients = (context.propsValue.bccRecipients ?? []) as string[]; + const attachments = (context.propsValue.attachments ?? []) as Array<{ file: ApFile; fileName: string }>; const { subject, body, bodyFormat } = context.propsValue; diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/download-email-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/download-email-attachment.ts index 82ec56e9c825..54e588974e1f 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/download-email-attachment.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/download-email-attachment.ts @@ -3,6 +3,7 @@ import { PageCollection } from '@microsoft/microsoft-graph-client'; import { FileAttachment } from '@microsoft/microsoft-graph-types'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; +import { downloadAttachmentActionOutputSchema } from '../output-schemas'; export const downloadAttachmentAction = createAction({ auth: microsoftOutlookAuth, @@ -10,8 +11,9 @@ export const downloadAttachmentAction = createAction({ classification: 'READ', displayName: 'Download Attachment', description: 'Download attachments from a specific email message.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetches all file attachments from a specific Outlook message (by message ID) and writes them to storage for downstream steps. Use this after locating a message to retrieve its attached files. Requires a valid message ID; idempotent since it only reads.', idempotent: true }, + outputSchema: downloadAttachmentActionOutputSchema, props: { messageId: Property.ShortText({ displayName: 'Message ID', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts index 1cb89d04a70b..aaf7b1ac77b8 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { mailFolderIdDropdown } from '../common/props'; +import { findEmailActionOutputSchema } from '../output-schemas'; export const findEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -14,6 +15,7 @@ export const findEmailAction = createAction({ description: 'Searches for emails using full-text search.', audience: 'both', aiMetadata: { description: 'Searches the Outlook mailbox for messages matching a full-text query (supports field syntax like from:, subject:, hasAttachments:), optionally scoped to one folder and capped by a max-results count. Use this to locate emails and obtain their message IDs for follow-up actions. Idempotent read-only lookup.', idempotent: true }, + outputSchema: findEmailActionOutputSchema, props: { searchQuery: Property.ShortText({ displayName: 'Search Query', @@ -41,9 +43,8 @@ export const findEmailAction = createAction({ const baseUrl = folderId ? `${outlookCommon.mailboxPrefix(context.auth)}/mailFolders/${folderId}/messages` : `${outlookCommon.mailboxPrefix(context.auth)}/messages`; const searchParam = `$search="${searchQuery}"`; const topParam = top ? `$top=${Math.min(Math.max(top, 1), 1000)}` : '$top=25'; - const selectParam = ['id', 'subject', 'from', 'toRecipients', 'receivedDateTime'].join(','); - const queryParams = [searchParam, topParam, selectParam].filter(Boolean).join('&'); + const queryParams = [searchParam, topParam].filter(Boolean).join('&'); const url = `${baseUrl}?${queryParams}`; const headers: Record = { diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts index 9bd717d4c97b..a0efa57fd836 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts @@ -3,6 +3,7 @@ import { BodyType, Message } from '@microsoft/microsoft-graph-types'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { messageIdDropdown } from '../common/props'; +import { forwardEmailActionOutputSchema } from '../output-schemas'; export const forwardEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -12,6 +13,7 @@ export const forwardEmailAction = createAction({ description: 'Forwards an email message.', audience: 'both', aiMetadata: { description: 'Forwards an existing Outlook message (by message ID) to new recipients, preserving the original body and attachments and prepending an optional comment. Use this to pass an existing email along rather than composing a new one. Not idempotent: each call sends a new forwarded email.', idempotent: false }, + outputSchema: forwardEmailActionOutputSchema, props: { messageId: messageIdDropdown({ displayName: 'Email', @@ -49,7 +51,7 @@ export const forwardEmailAction = createAction({ attachments: message.attachments, }; - const response = await client + await client .api(`${outlookCommon.mailboxPrefix(context.auth)}/messages/${messageId}/forward`) .post({ message:messagePayload, @@ -58,8 +60,7 @@ export const forwardEmailAction = createAction({ return { success: true, message: 'Email forwarded successfully.', - messageId: response.id, - ...response, + messageId, }; }, }); diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts index 3b9e85fdea29..b34525f3df4e 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { mailFolderIdDropdown, messageIdDropdown } from '../common/props'; +import { messageActionOutputSchema } from '../output-schemas'; export const moveEmailToFolderAction = createAction({ auth: microsoftOutlookAuth, @@ -11,6 +12,7 @@ export const moveEmailToFolderAction = createAction({ description: 'Moves an email message to a specific folder.', audience: 'both', aiMetadata: { description: 'Moves a specific Outlook message into a chosen mail folder. Use this to organize, archive, or route an email after processing it. Note: the move assigns a new message ID, so re-running with the original ID will fail once moved.', idempotent: false }, + outputSchema: messageActionOutputSchema, props: { messageId: messageIdDropdown({ displayName: 'Email', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts index 98037dd22fb9..efe826bed8bf 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { messageIdDropdown } from '../common/props'; +import { messageActionOutputSchema } from '../output-schemas'; export const removeLabelFromEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -11,6 +12,7 @@ export const removeLabelFromEmailAction = createAction({ description: 'Removes a category (label) from an email message.', audience: 'both', aiMetadata: { description: 'Removes one or more Outlook categories (labels) from a specific message, leaving any other categories intact. Use this to untag or reclassify an email. Idempotent: re-running with the same categories yields the same final label set.', idempotent: true }, + outputSchema: messageActionOutputSchema, props: { messageId: messageIdDropdown({ displayName: 'Email', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts index c34f464c56e3..19f7b1f7e1e7 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts @@ -3,6 +3,7 @@ import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { BodyType, Message } from '@microsoft/microsoft-graph-types'; import { PageCollection } from '@microsoft/microsoft-graph-client'; +import { replyEmailActionOutputSchema } from '../output-schemas'; export const replyEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -12,6 +13,7 @@ export const replyEmailAction = createAction({ description: 'Reply to an outlook email.', audience: 'both', aiMetadata: { description: 'Replies to an existing Outlook message (identified by message ID), supporting added CC/BCC recipients and attachments. Set the Create Draft flag to stage the reply without sending; otherwise it is sent immediately. Not idempotent when sending: each call creates and dispatches a new reply.', idempotent: false }, + outputSchema: replyEmailActionOutputSchema, props: { messageId: Property.Dropdown({ auth: microsoftOutlookAuth, @@ -100,9 +102,9 @@ export const replyEmailAction = createAction({ }, async run(context) { const { replyBody, bodyFormat, messageId, draft } = context.propsValue; - const ccRecipients = context.propsValue.ccRecipients as string[]; - const bccRecipients = context.propsValue.bccRecipients as string[]; - const attachments = context.propsValue.attachments as Array<{ + const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[]; + const bccRecipients = (context.propsValue.bccRecipients ?? []) as string[]; + const attachments = (context.propsValue.attachments ?? []) as Array<{ file: ApFile; fileName: string; }>; diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/request-approval-send-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/request-approval-send-email.ts index c39f91c21214..346c0fd60ca0 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/request-approval-send-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/request-approval-send-email.ts @@ -4,6 +4,7 @@ import { assertNotNullOrUndefined } from '@activepieces/pieces-framework'; import { ExecutionType } from '@activepieces/pieces-framework'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; +import { requestApprovalActionOutputSchema } from '../output-schemas'; export const requestApprovalInMail = createAction({ auth: microsoftOutlookAuth, @@ -14,6 +15,7 @@ export const requestApprovalInMail = createAction({ 'Send approval request email and then wait until the email is approved or disapproved', audience: 'both', aiMetadata: { description: 'Sends an email with a single link to a confirmation page where the recipient chooses Approve or Disapprove, then pauses the flow until they respond, resuming with the decision. Use this as a human-in-the-loop approval gate before proceeding. Not idempotent: each call sends a new email and creates a new pending waitpoint.', idempotent: false }, + outputSchema: requestApprovalActionOutputSchema, props: { recipients: Property.ShortText({ displayName: 'To Email Address', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts index 86bf5055960c..da207fa374d3 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts @@ -2,6 +2,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { draftMessageIdDropdown } from '../common/props'; +import { sendDraftEmailActionOutputSchema } from '../output-schemas'; export const sendDraftEmailAction = createAction({ auth: microsoftOutlookAuth, @@ -11,6 +12,7 @@ export const sendDraftEmailAction = createAction({ description: 'Sends a draft email message.', audience: 'both', aiMetadata: { description: 'Sends an existing draft email (identified by draft message ID) from the Outlook mailbox. Use this to dispatch a draft previously staged by Create Draft Email or a draft reply. Not idempotent: once sent the draft no longer exists, so re-running with the same ID will fail.', idempotent: false }, + outputSchema: sendDraftEmailActionOutputSchema, props: { messageId: draftMessageIdDropdown({ displayName: 'Draft Email', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts index edf507a19ae5..11c5233dd240 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts @@ -65,9 +65,9 @@ export const sendEmailAction = createAction({ }, async run(context) { const recipients = context.propsValue.recipients as string[]; - const ccRecipients = context.propsValue.ccRecipients as string[]; - const bccRecipients = context.propsValue.bccRecipients as string[]; - const attachments = context.propsValue.attachments as Array<{ file: ApFile; fileName: string }>; + const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[]; + const bccRecipients = (context.propsValue.bccRecipients ?? []) as string[]; + const attachments = (context.propsValue.attachments ?? []) as Array<{ file: ApFile; fileName: string }>; const { subject, body, bodyFormat } = context.propsValue; diff --git a/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts b/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts new file mode 100644 index 000000000000..75c991c65376 --- /dev/null +++ b/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts @@ -0,0 +1,107 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const emailAddressFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Name', value: 'emailAddress.name' }, + { key: 'address', label: 'Email Address', value: 'emailAddress.address', format: 'email' }, +]; + +const messageFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Message ID' }, + { key: 'subject', label: 'Subject' }, + { key: 'bodyPreview', label: 'Body Preview' }, + { + key: 'body', + label: 'Body', + children: [ + { key: 'contentType', label: 'Content Type' }, + { key: 'content', label: 'Content' }, + ], + }, + { key: 'from', label: 'From', children: emailAddressFields }, + { key: 'sender', label: 'Sender', children: emailAddressFields }, + { key: 'toRecipients', label: 'To Recipients', labelKey: 'address', listItems: emailAddressFields }, + { key: 'ccRecipients', label: 'CC Recipients', labelKey: 'address', listItems: emailAddressFields }, + { key: 'bccRecipients', label: 'BCC Recipients', labelKey: 'address', listItems: emailAddressFields }, + { key: 'replyTo', label: 'Reply To', labelKey: 'address', listItems: emailAddressFields }, + { key: 'receivedDateTime', label: 'Received At', format: 'datetime' }, + { key: 'sentDateTime', label: 'Sent At', format: 'datetime' }, + { key: 'createdDateTime', label: 'Created At', format: 'datetime' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'hasAttachments', label: 'Has Attachments', format: 'boolean' }, + { key: 'isRead', label: 'Is Read', format: 'boolean' }, + { key: 'isDraft', label: 'Is Draft', format: 'boolean' }, + { key: 'importance', label: 'Importance' }, + { key: 'categories', label: 'Categories' }, + { key: 'flag', label: 'Flag', children: [{ key: 'flagStatus', label: 'Flag Status' }] }, + { key: 'webLink', label: 'Web Link', format: 'url' }, + { key: 'parentFolderId', label: 'Parent Folder ID' }, + { key: 'conversationId', label: 'Conversation ID' }, + { key: 'internetMessageId', label: 'Internet Message ID' }, +]; + +const attachmentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Attachment ID' }, + { key: 'name', label: 'File Name' }, + { key: 'file', label: 'File', format: 'url' }, + { key: 'contentType', label: 'Content Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'isInline', label: 'Is Inline', format: 'boolean' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, +]; + +const dispatchResultFields: OutputSchema['fields'] = [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'message', label: 'Message' }, + { key: 'messageId', label: 'Message ID' }, +]; + +export const messageActionOutputSchema: OutputSchema = { fields: messageFields }; + +export const draftMessageActionOutputSchema: OutputSchema = { + fields: messageFields.filter((field) => field.key !== 'from' && field.key !== 'sender'), +}; + +export const findEmailActionOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'totalCount', label: 'Total Count', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + { key: 'nextPageUrl', label: 'Next Page URL', format: 'url' }, + { key: 'result', label: 'Emails', labelKey: 'subject', listItems: messageFields }, + ], +}; + +export const downloadAttachmentActionOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [{ key: 'attachments', label: 'Attachments', value: '', listItems: attachmentFields }], +}; + +export const sendDraftEmailActionOutputSchema: OutputSchema = { fields: dispatchResultFields }; + +export const forwardEmailActionOutputSchema: OutputSchema = { fields: dispatchResultFields }; + +export const replyEmailActionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'message', label: 'Message' }, + { key: 'draftId', label: 'Draft ID' }, + { key: 'draftLink', label: 'Draft Link', format: 'url' }, + ], +}; + +export const requestApprovalActionOutputSchema: OutputSchema = { + fields: [{ key: 'approved', label: 'Approved', format: 'boolean' }], +}; + +export const newEmailTriggerOutputSchema: OutputSchema = { fields: messageFields }; + +export const newAttachmentTriggerOutputSchema: OutputSchema = { + fields: [ + ...attachmentFields, + { key: 'messageId', label: 'Message ID' }, + { key: 'messageSubject', label: 'Message Subject' }, + { key: 'messageSender', label: 'Message Sender', children: emailAddressFields }, + { key: 'messageReceivedDateTime', label: 'Message Received At', format: 'datetime' }, + { key: 'parentFolderId', label: 'Parent Folder ID' }, + ], +}; diff --git a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts index ff2374dd48ce..9fe572a705aa 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts @@ -6,6 +6,7 @@ import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { mailFolderIdDropdown } from '../common/props'; import { isNil } from '@activepieces/pieces-framework'; +import { newAttachmentTriggerOutputSchema } from '../output-schemas'; async function enrichAttachments( client: Client, @@ -63,6 +64,7 @@ export const newAttachmentTrigger = createTrigger({ aiMetadata: { description: 'Fires once per attachment when a new email carrying one or more file attachments arrives, optionally scoped to a folder, sender, or attachment-name filter. Each fire represents a single attachment from a newly received message.', }, + outputSchema: newAttachmentTriggerOutputSchema, props: { folderId: mailFolderIdDropdown({ displayName: 'Folder', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email-in-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email-in-folder.ts index ef1631d3970d..e7a4d3956804 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email-in-folder.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email-in-folder.ts @@ -11,6 +11,7 @@ import dayjs from 'dayjs'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; import { mailFolderIdDropdown } from '../common/props'; +import { newEmailTriggerOutputSchema } from '../output-schemas'; const polling: Polling, { folderId?: string }> = { strategy: DedupeStrategy.TIMEBASED, @@ -64,6 +65,7 @@ export const newEmailInFolderTrigger = createTrigger({ aiMetadata: { description: 'Fires when a new message appears in the chosen Outlook mail folder. Each fire represents one new email added to that folder.', }, + outputSchema: newEmailTriggerOutputSchema, props: { folderId: mailFolderIdDropdown({ displayName: 'Folder', diff --git a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email.ts index bd9580c691ab..1a8cce68aa4e 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-email.ts @@ -10,6 +10,7 @@ import { Message } from '@microsoft/microsoft-graph-types'; import dayjs from 'dayjs'; import { microsoftOutlookAuth } from '../common/auth'; import { outlookCommon } from '../common/client'; +import { newEmailTriggerOutputSchema } from '../output-schemas'; const polling: Polling, { sender?: string; @@ -90,6 +91,7 @@ export const newEmailTrigger = createTrigger({ aiMetadata: { description: 'Fires when a new message arrives in the mailbox Inbox, optionally narrowed to a specific sender and/or recipient address. Each fire represents one newly received email.', }, + outputSchema: newEmailTriggerOutputSchema, props: { sender: Property.ShortText({ displayName: 'From (Sender Email)', diff --git a/packages/server/api/src/app/database/migration/postgres/1818000000000-AddCellCascadeIndices.ts b/packages/server/api/src/app/database/migration/postgres/1818000000000-AddCellCascadeIndices.ts new file mode 100644 index 000000000000..396ffb4d66f1 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1818000000000-AddCellCascadeIndices.ts @@ -0,0 +1,42 @@ +import { QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' +import { AppSystemProp } from '../../../helper/system/system-props' +import { DatabaseType } from '../../database-type' +import { Migration } from '../../migration' + +export class AddCellCascadeIndices1818000000000 implements Migration { + name = 'AddCellCascadeIndices1818000000000' + breaking = false + release = '0.86.4' + transaction = false + + public async up(queryRunner: QueryRunner): Promise { + if (isPGlite()) { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_cell_record_id" + ON "cell" ("recordId") + `) + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_cell_field_id" + ON "cell" ("fieldId") + `) + } + else { + await queryRunner.query(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_cell_record_id" + ON "cell" ("recordId") + `) + await queryRunner.query(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_cell_field_id" + ON "cell" ("fieldId") + `) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX IF EXISTS "idx_cell_field_id"') + await queryRunner.query('DROP INDEX IF EXISTS "idx_cell_record_id"') + } +} + +const isPGlite = (): boolean => system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 918b00b2732b..1ab49d593ca6 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -407,6 +407,7 @@ import { AddSampleDataFlowIdIndexToFile1815000000000 } from './migration/postgre import { AddTeamsBotInstallation1816000000000 } from './migration/postgres/1816000000000-AddTeamsBotInstallation' import { AddUserChatMemory1817000000000 } from './migration/postgres/1817000000000-AddUserChatMemory' import { AddAutumnBillingColumnsToPlatformPlan1818000000000 } from './migration/postgres/1818000000000-AddAutumnBillingColumnsToPlatformPlan' +import { AddCellCascadeIndices1818000000000 } from './migration/postgres/1818000000000-AddCellCascadeIndices' import { AddFieldPosition1818000000000 } from './migration/postgres/1818000000000-AddFieldPosition' import { AddAgentConversationSource1819000000000 } from './migration/postgres/1819000000000-AddAgentConversationSource' import { DropPieceTags1819000000000 } from './migration/postgres/1819000000000-DropPieceTags' @@ -841,6 +842,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddSampleDataFlowIdIndexToFile1815000000000, AddTeamsBotInstallation1816000000000, AddUserChatMemory1817000000000, + AddCellCascadeIndices1818000000000, AddAutumnBillingColumnsToPlatformPlan1818000000000, AddFieldPosition1818000000000, DropPieceTags1819000000000, diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts index 90f1196ca5d5..ec6420c6c4ac 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts @@ -6,6 +6,7 @@ import { recordService } from '../../tables/record/record.service' import { mcpUtils } from './mcp-utils' const deleteRecordsInput = z.object({ + tableId: z.string().describe('ID of the table the records belong to. Use ap_find_records to find it.'), recordIds: z.array(z.string()).describe('Array of record IDs to delete. Use ap_find_records to find them.'), displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete 3 records from Emails table"). Must include what the action does and the target name.'), }) @@ -19,21 +20,22 @@ export const apDeleteRecordsTool = (mcp: ProjectScopedMcpServer, log: FastifyBas annotations: { destructiveHint: true, openWorldHint: false }, execute: async (args) => { try { - const { recordIds } = deleteRecordsInput.parse(args) + const { tableId, recordIds } = deleteRecordsInput.parse(args) if (recordIds.length === 0) { return { content: [{ type: 'text', text: '❌ No record IDs provided.' }] } } - const deleted = await recordService.delete({ + const { deletedCount } = await recordService.delete({ ids: recordIds, projectId: mcp.projectId, + tableId, }) return { content: [{ type: 'text', - text: `✅ Deleted ${deleted.length} record(s).`, + text: `✅ Deleted ${deletedCount} record(s).`, }], } } diff --git a/packages/server/api/src/app/tables/record/cell.entity.ts b/packages/server/api/src/app/tables/record/cell.entity.ts index 05c8d57a7d96..6ae7d111336d 100644 --- a/packages/server/api/src/app/tables/record/cell.entity.ts +++ b/packages/server/api/src/app/tables/record/cell.entity.ts @@ -34,6 +34,14 @@ export const CellEntity = new EntitySchema({ columns: ['projectId', 'fieldId', 'recordId'], unique: true, }, + { + name: 'idx_cell_record_id', + columns: ['recordId'], + }, + { + name: 'idx_cell_field_id', + columns: ['fieldId'], + }, ], relations: { record: { diff --git a/packages/server/api/src/app/tables/record/record-side-effects.ts b/packages/server/api/src/app/tables/record/record-side-effects.ts index e41aed8de6a3..0e590953c432 100644 --- a/packages/server/api/src/app/tables/record/record-side-effects.ts +++ b/packages/server/api/src/app/tables/record/record-side-effects.ts @@ -1,20 +1,57 @@ +import { chunk } from '@activepieces/core-utils' import { PopulatedRecord, TableAutomationTrigger, TableWebhookEventType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' -import { recordService } from './record.service' +import { WebhookFlowVersionToRun, webhookService } from '../../webhooks/webhook.service' +import { tableService } from '../table/table.service' -type BulkSideEffectParams = { - projectId: string - tableId: string - records: PopulatedRecord[] - logger: FastifyBaseLogger - authorization: string - agentUpdate?: boolean -} +export const recordSideEffects = (_log: FastifyBaseLogger) => ({ + async handleRecordsEvent( + params: BulkSideEffectParams, + eventKey: keyof typeof EVENT_TYPE_MAP, + ) { + const { projectId, tableId, records, logger, authorization } = params + if (records.length === 0) { + return + } + const { eventType } = EVENT_TYPE_MAP[eventKey] + + const webhooks = await tableService.getWebhooks({ + projectId, + id: tableId, + events: [eventType], + }) + if (webhooks.length === 0) { + return + } + + const dispatches = records.flatMap((record) => + webhooks.map((webhook) => ({ record, webhook })), + ) + for (const batch of chunk(dispatches, MAX_CONCURRENT_WEBHOOK_DISPATCHES)) { + await Promise.all(batch.map(({ record, webhook }) => + webhookService.handleWebhook({ + async: true, + flowId: webhook.flowId, + flowVersionToRun: WebhookFlowVersionToRun.LOCKED_FALL_BACK_TO_LATEST, + saveSampleData: false, + data: async (_projectId: string) => ({ + method: 'POST', + headers: { + authorization, + }, + body: { record }, + queryParams: {}, + }), + execute: true, + logger, + failParentOnFailure: true, + }), + )) + } + }, +}) - type EventTypeWithAutomation = { - eventType: TableWebhookEventType - automationTrigger?: TableAutomationTrigger - } +const MAX_CONCURRENT_WEBHOOK_DISPATCHES = 50 const EVENT_TYPE_MAP: Record< 'created' | 'updated' | 'deleted', @@ -33,26 +70,16 @@ EventTypeWithAutomation }, } +type EventTypeWithAutomation = { + eventType: TableWebhookEventType + automationTrigger?: TableAutomationTrigger +} -export const recordSideEffects = (_log: FastifyBaseLogger) => ({ - async handleRecordsEvent( - params: BulkSideEffectParams, - eventKey: keyof typeof EVENT_TYPE_MAP, - ) { - const { projectId, tableId, records, logger, authorization } = params - const { eventType } = EVENT_TYPE_MAP[eventKey] - - await Promise.all( - records.map(async (record) => { - await recordService.triggerWebhooks({ - projectId, - tableId, - eventType, - data: { record }, - logger, - authorization, - }) - }), - ) - }, -}) \ No newline at end of file +type BulkSideEffectParams = { + projectId: string + tableId: string + records: PopulatedRecord[] + logger: FastifyBaseLogger + authorization: string + agentUpdate?: boolean +} diff --git a/packages/server/api/src/app/tables/record/record.controller.ts b/packages/server/api/src/app/tables/record/record.controller.ts index cd0685e890bf..3811b808a641 100644 --- a/packages/server/api/src/app/tables/record/record.controller.ts +++ b/packages/server/api/src/app/tables/record/record.controller.ts @@ -57,13 +57,14 @@ export const recordController: FastifyPluginAsyncZod = async (fastify) => { }) fastify.delete('/', DeleteRecordRequest, async (request, reply) => { - const deletedRecords = await recordService.delete({ + const { records: deletedRecords } = await recordService.delete({ ids: request.body.ids, projectId: request.projectId, + tableId: request.body.tableId, }) await reply.status(StatusCodes.OK).send([]) await recordSideEffects(fastify.log).handleRecordsEvent({ - tableId: deletedRecords[0]?.tableId, + tableId: request.body.tableId, projectId: request.projectId, records: deletedRecords, logger: request.log, diff --git a/packages/server/api/src/app/tables/record/record.service.ts b/packages/server/api/src/app/tables/record/record.service.ts index 4805b05b3df4..408af1a6ca2e 100644 --- a/packages/server/api/src/app/tables/record/record.service.ts +++ b/packages/server/api/src/app/tables/record/record.service.ts @@ -6,7 +6,6 @@ import { repoFactory } from '../../core/db/repo-factory' import { transaction } from '../../core/db/transaction' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' -import { WebhookFlowVersionToRun, webhookService } from '../../webhooks/webhook.service' import { FieldEntity } from '../field/field.entity' import { fieldService } from '../field/field.service' import { tableService } from '../table/table.service' @@ -229,102 +228,44 @@ export const recordService = { async delete({ ids, projectId, - }: DeleteParams): Promise { - const firstRecord = await recordRepo().findOne({ - where: { id: ids[0], projectId }, - select: ['tableId'], - }) - if (isNil(firstRecord)) { + tableId, + }: DeleteParams): Promise { + const uniqueIds = [...new Set(ids)] + if (uniqueIds.length === 0) { + return { deletedCount: 0, records: [] } + } + const maxRecordsPerTable = system.getNumberOrThrow(AppSystemProp.MAX_RECORDS_PER_TABLE) + if (uniqueIds.length > maxRecordsPerTable) { throw new ActivepiecesError({ - code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityType: 'Record', entityId: ids[0] }, + code: ErrorCode.VALIDATION, + params: { + message: `Max records per delete request reached: ${maxRecordsPerTable}`, + }, }) } - - const records = await recordRepo().find({ - where: { id: In(ids), projectId, tableId: firstRecord.tableId }, - relations: ['cells'], - }) - - await recordRepo().delete({ - id: In(ids), + const { deletedIds, records } = await deleteRecordsAndReturnWebhookPayloads({ projectId, - tableId: firstRecord.tableId, + tableId, + recordIds: uniqueIds, }) - - if (records.length === 0) { - return [] + if (deletedIds.length === 0) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityType: 'Record', entityId: uniqueIds[0] }, + }) + } + return { + deletedCount: deletedIds.length, + records, } - - return formatRecordsAndFetchField({ records, tableId: firstRecord.tableId, projectId }) }, async deleteAll({ tableId, projectId, }: DeleteAllParams): Promise { - const deletedRecords = await transaction(async (entityManager: EntityManager) => { - const records = await entityManager.getRepository(RecordEntity).find({ - where: { projectId, tableId }, - relations: ['cells'], - }) - - const recordIds = records.map((record) => record.id) - - if (recordIds.length > 0) { - await entityManager.getRepository(RecordEntity).delete({ - id: In(recordIds), - projectId, - tableId, - }) - } - - return records - }) - - if (deletedRecords.length === 0) { - return [] - } - - return formatRecordsAndFetchField({ records: deletedRecords, tableId, projectId }) - }, - - async triggerWebhooks({ - projectId, - tableId, - eventType, - data, - logger, - authorization, - }: TriggerWebhooksParams): Promise { - const webhooks = await tableService.getWebhooks({ - projectId, - id: tableId, - events: [eventType], - }) - - if (webhooks.length === 0) { - return - } - await Promise.all(webhooks.map((webhook) => { - return webhookService.handleWebhook({ - async: true, - flowId: webhook.flowId, - flowVersionToRun: WebhookFlowVersionToRun.LOCKED_FALL_BACK_TO_LATEST, - saveSampleData: false, - data: async (_projectId: string) => ({ - method: 'POST', - headers: { - authorization, - }, - body: data, - queryParams: {}, - }), - execute: true, - logger, - failParentOnFailure: true, - }) - })) + const { records } = await deleteRecordsAndReturnWebhookPayloads({ projectId, tableId }) + return records }, async count({ projectId, tableId }: CountParams): Promise { @@ -380,7 +321,78 @@ function prepareCellInsertions( ) } +async function loadRecordsForDeleteWebhook({ + projectId, + tableId, + recordIds, +}: { + projectId: string + tableId: string + recordIds?: string[] +}): Promise { + const webhooks = await tableService.getWebhooks({ + projectId, + id: tableId, + events: [TableWebhookEventType.RECORD_DELETED], + }) + if (webhooks.length === 0) { + return [] + } + return recordRepo().find({ + where: isNil(recordIds) ? { projectId, tableId } : { id: In(recordIds), projectId, tableId }, + relations: ['cells'], + }) +} + +async function deleteRecordsReturningIds({ + projectId, + tableId, + recordIds, +}: { + projectId: string + tableId: string + recordIds?: string[] +}): Promise { + const result = await recordRepo() + .createQueryBuilder() + .delete() + .where(isNil(recordIds) ? { projectId, tableId } : { id: In(recordIds), projectId, tableId }) + .returning('id') + .execute() + const deletedRows: unknown = result.raw + if (!Array.isArray(deletedRows)) { + return [] + } + return deletedRows.filter(isRowWithId).map((row) => row.id) +} + +function isRowWithId(row: unknown): row is { id: string } { + return typeof row === 'object' && !isNil(row) && typeof Reflect.get(row, 'id') === 'string' +} + +async function deleteRecordsAndReturnWebhookPayloads({ + projectId, + tableId, + recordIds, +}: { + projectId: string + tableId: string + recordIds?: string[] +}): Promise<{ deletedIds: string[], records: PopulatedRecord[] }> { + const snapshot = await loadRecordsForDeleteWebhook({ projectId, tableId, recordIds }) + const deletedIds = await deleteRecordsReturningIds({ projectId, tableId, recordIds }) + const deletedIdSet = new Set(deletedIds) + const deletedRecords = snapshot.filter((record) => deletedIdSet.has(record.id)) + return { + deletedIds, + records: await formatRecordsAndFetchField({ records: deletedRecords, tableId, projectId }), + } +} + async function formatRecordsAndFetchField({ records, tableId, projectId, fields: prefetchedFields }: { records: RecordSchema[], tableId: string, projectId: string, fields?: Field[] }): Promise { + if (records.length === 0) { + return [] + } const fields = prefetchedFields ?? await fieldService.getAll({ tableId, projectId, @@ -512,6 +524,12 @@ type UpdateParams = { type DeleteParams = { ids: string[] projectId: string + tableId: string +} + +type DeleteRecordsResult = { + deletedCount: number + records: PopulatedRecord[] } type DeleteAllParams = { @@ -519,14 +537,6 @@ type DeleteAllParams = { projectId: string } -type TriggerWebhooksParams = { - projectId: string - tableId: string - eventType: TableWebhookEventType - data: Record - logger: FastifyBaseLogger - authorization: string -} type CountParams = { projectId: string tableId: string diff --git a/packages/server/api/test/integration/ce/tables/record.test.ts b/packages/server/api/test/integration/ce/tables/record.test.ts index a1a0f366b3dc..78f9eaca5348 100644 --- a/packages/server/api/test/integration/ce/tables/record.test.ts +++ b/packages/server/api/test/integration/ce/tables/record.test.ts @@ -531,6 +531,90 @@ describe('Record API', () => { expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) }) + + it('should delete existing records when the first id does not exist', async () => { + const ctx = await setup() + const { table } = await createTableWithField(ctx) + const record1 = createMockRecord({ tableId: table.id, projectId: ctx.project.id }) + const record2 = createMockRecord({ tableId: table.id, projectId: ctx.project.id }) + await db.save('record', [record1, record2]) + + const response = await ctx.inject({ + method: 'DELETE', + url: '/api/v1/records', + body: { + tableId: table.id, + ids: [apId(), record1.id, record2.id], + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const getResponse1 = await ctx.get(`/v1/records/${record1.id}`) + expect(getResponse1?.statusCode).toBe(StatusCodes.NOT_FOUND) + const getResponse2 = await ctx.get(`/v1/records/${record2.id}`) + expect(getResponse2?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('should delete more records than one batch in a single request', async () => { + const ctx = await setup() + const { table } = await createTableWithField(ctx) + const records = Array.from({ length: 120 }, () => + createMockRecord({ tableId: table.id, projectId: ctx.project.id }), + ) + await db.save('record', records) + + const response = await ctx.inject({ + method: 'DELETE', + url: '/api/v1/records', + body: { + tableId: table.id, + ids: records.map((record) => record.id), + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const listResponse = await ctx.get(`/v1/records?tableId=${table.id}`) + expect(listResponse?.statusCode).toBe(StatusCodes.OK) + expect(listResponse?.json().data.length).toBe(0) + }) + + it('should reject a delete request with more ids than the per-table record limit', async () => { + const ctx = await setup() + const { table } = await createTableWithField(ctx) + + const response = await ctx.inject({ + method: 'DELETE', + url: '/api/v1/records', + body: { + tableId: table.id, + ids: Array.from({ length: 10001 }, () => apId()), + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('should not delete records that belong to another table', async () => { + const ctx = await setup() + const { table } = await createTableWithField(ctx) + const otherTable = await createTableWithField(ctx) + const record = createMockRecord({ tableId: table.id, projectId: ctx.project.id }) + const otherRecord = createMockRecord({ tableId: otherTable.table.id, projectId: ctx.project.id }) + await db.save('record', [record, otherRecord]) + + const response = await ctx.inject({ + method: 'DELETE', + url: '/api/v1/records', + body: { + tableId: table.id, + ids: [record.id, otherRecord.id], + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect((await ctx.get(`/v1/records/${record.id}`))?.statusCode).toBe(StatusCodes.NOT_FOUND) + expect((await ctx.get(`/v1/records/${otherRecord.id}`))?.statusCode).toBe(StatusCodes.OK) + }) }) })