diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index cf3b4470af89..9e31aaf14ec4 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -146,11 +146,7 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the page showed the global "Failed to load data" dialog of the day. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. What made this class of bug hard to place is that the surface was then page-independent (raised from `QueryCache.onError` in `query-client.ts`) and React Query's 3 default retries meant it landed several seconds later on whatever page you navigated to next (the report was against `/platform/setup/general`). The tab now renders `DataFetchErrorState` in place instead, so a repeat would at least accuse the right page. When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. - **The AI Center providers page renders its whole state machine off one query, so every branch needs an `isLoading` gate above it.** `ProvidersTab` derives the empty state, the grouped list *and* the `?config=` detail view from `useAiProviderConfigs()`. While that query is pending, `data` is `undefined` — `configs` is `[]`, so the empty "Connect your first provider" screen paints for a frame, and a hard refresh on a `?config=` URL finds no matching row and falls through to the list before flipping to the detail page. The parent `SuspenseWrapper` does not cover it: the route chunk and the suspense-backed projects collection resolve first, then the component mounts with no providers yet. Any new branch added to this page belongs below the `if (isLoading) return ` gate, not beside it. - **A save bar in the AI Center needs a full-height column, not `fixed` and not bare `sticky`.** The page sits inside *two* nested scrollers — `PlatformLayout`'s `flex flex-col flex-1 overflow-auto` and the AI Center's own `flex-1 min-w-0 overflow-auto` beside the `w-60` tab rail — so `fixed inset-x-0` centres the bar on the viewport, roughly a sidebar-width left of the content column it belongs to. Swapping it to `sticky bottom-4` fixes the centring for free but pins nothing when the content is shorter than the viewport: the bar just lands wherever the content ends, mid-page. The working shape is `min-h-full` + `flex flex-col` on the tab-content wrapper, `flex-1` on the `TabsContent`, `grow` on the panel root, then `sticky bottom-4 mt-auto` on the bar — `mt-auto` handles the short page, `sticky` handles the long one. `min-h-full` resolves only because every ancestor up to `SidebarInset h-full` has a definite height; that chain is what makes the whole thing work, so check it before reusing this elsewhere. - -### Key files - -Entry point: `aiProviderModule`, registered in `packages/server/api/src/app/app.ts` right after `aiProviderService(app.log).setup()`. - +- **A chat *tier* is our own idea, so it has no meaning on a provider we ship no curated model list for — resolve it by asking the key what it offers.** `ACTIVEPIECES_CHAT_TIERS` (fast/smart/premium) only maps to a real id through `ALLOWED_CHAT_MODELS_BY_PROVIDER`, which covers Anthropic, OpenAI, Google, Vertex and Activepieces. Everything else — Bedrock, Azure, Mistral, xAI, DeepSeek and the other OpenAI-compatible vendors — has an empty candidate list. The resolver used to invent an id from the tier (strip the vendor prefix, dots to dashes: `anthropic/claude-haiku-4.5` -> `claude-haiku-4-5`), which Bedrock rejected as "The provided model identifier is invalid" (GIT-1840, Pylon 5836). #15355 removed the invention and left a throw in its place, which was worse: every agent turn on those providers died with `ENTITY_NOT_FOUND: this AI provider key allows no text model a chat turn can run on` before the configured model handled anything (Pylon 6069). **The missing ingredient was never a smarter guess, it was the key's own model list.** `agentHelpers.resolveModelId` is now the single resolver for every surface: it tries our catalogue first and, only when that has nothing to say, reads `aiProviderService.listModels` (process-cached, cleared nightly by cron, already filtered by the key's `modelScope`), preferring a model whose id *contains* the tier's `nativeModelId` — Bedrock spells them `eu.anthropic.claude-sonnet-4-6-...`, so a tier still selects the model it names wherever the vendor hosts it, and otherwise degrades to the first text model the way `pickAllowedModel` already does for an admin-listed catalogue. Picking from a list the provider returned is the opposite of the fabrication #15355 removed. **Two traps, both learned the hard way.** First, an agent surface and generic chat are not symmetric: an agent *carries* a real model id (its picker lists `listModels` output unfiltered, precisely because these providers have no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry) while chat carries only a tier name, because `ChatModelSelector` falls back to tier options when `getCuratedChatModels` returns `undefined`. Second, **being listed proves the key can see a model, not that the account can invoke it** — so where a model is already settled the fast round reuses it rather than choosing again (`getAgentConfig` passes the model the turn resolved; the tool RPC reads the run's own model off the conversation or the agent via `pinnedModelOf` — and it has to read the *same* half the run did: an agent-page chat runs on `agent.draft` (`agent-conversation-controller.ts`), a Run Agent step on `agent.published`, so reading the wrong one silently lends the tool a stale model whenever the two differ). Resolving the fast tier independently once picked a Haiku the account could not invoke and failed a turn whose configured Sonnet worked fine. A borrowed model still has to pass the key's current `modelScope`, or resolution falls through to the list, which applies it. Nothing crosses the worker, so a rolling deploy has no version-skew window. - `packages/server/api/src/app/ai/` — backend module: provider controller, service, entity, module, plus the sibling ai-tool-config files - `packages/server/api/src/app/ai/providers/` — per-vendor strategies keyed by `AIProviderName` - `packages/server/api/src/app/platform/billing-provider.ts` — `assertCreditsAndAppSumoNotExceeded` credit gate diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index e130b21e7836..d2cd7b043eea 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -38,6 +38,9 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, ### Gotchas +- **The consent page (`/mcp-authorize`) must treat an `ONBOARDING` token as signed out.** `isLoggedIn()` is only a JWT-expiry check; `GET /v1/projects` answers 403 for that principal, so gating on it alone rendered an empty project select with Authorize disabled forever. It now also checks `isOnboarding()` and sends the visitor to `/sign-in?from=…`, where the drawer resumes at the name step and returns to consent. +- **`/verify-email` cannot see the OAuth `from=`** (the link is server-generated), so both `SignUpForm` and `SignInForm` store it via `pendingRedirect` in `navigation-utils` when they show the check-your-email note, and `/verify-email` hands it back as `/sign-in?from=…`. Only matters when passwordless is off — the code flow never leaves the page. +- **Deny goes through `POST /v1/mcp-oauth/deny`**, which verifies the `authRequestId` JWT and returns the client's redirect with `error=access_denied` (it used to be `history.back()`, a no-op in the tab the client opened, so the client never heard back). The consent page only *decodes* the JWT for display: `clientName`, the platform/project scope, and `exp` so an expired request says "go back to {client}" instead of inviting a retry. The browser clock only drives that notice, never the buttons (a clock 10 minutes fast would otherwise lock the user out of a request the server still accepts); Authorize and Deny are disabled only after the server itself answers 400 `invalid_request`. Switch account is a full page load, not a `navigate`, because the query cache would otherwise hand the previous account's projects to the next one. It also preselects a single project and names the signed-in account with a Switch account link, because a browser already signed into another workspace would otherwise authorize it silently. - **`mcp_server.token` is dead — nothing reads it.** It is written by the `getOrCreate` defaults and by both `/rotate` routes (`mcpServerService.rotateToken` / `rotatePlatformToken`), and consulted by **no authenticator**, so "rotating" it rotates a secret that grants nothing. It is still on the public `McpServer` zod schema, so the API keeps shipping a secret-shaped 72-char string that authenticates nothing — do not reach for it as a credential, and do not tell a self-hoster to. The settings panel is consistent with reality already (`mcp-credentials.tsx` renders the URL and *"Authentication is handled via OAuth"*, never a token). Deleting the column, the two routes, and the schema field is a breaking API-response change and has not been done. - **`mcp_oauth_token.clientKey` is decided once, at sign-in.** `exchangeCode` derives it from the registration's redirect URIs via `mcpOAuthClientIdentity`, so the grants list can filter and group in SQL instead of loading diff --git a/brain/knowledge/data-storage-observability/index.md b/brain/knowledge/data-storage-observability/index.md index a2a84c972058..75a895c7ef6c 100644 --- a/brain/knowledge/data-storage-observability/index.md +++ b/brain/knowledge/data-storage-observability/index.md @@ -65,6 +65,19 @@ Gotchas: - The Google `USER_SIGNED_UP` audit event is gated on `signedUp` too; before that it fired on every login. - SAML users are never stamped: they are provisioned by their platform admin, not by a campaign link. +### Product Lifecycle Telemetry + +Server-side PostHog events for the moments between sign-up and payment: `onboarding.completed` (name step done), `invite.sent` / `invite.accepted`, `checkout.started`, `plan.changed`, `plan.cancelled`, `plan.reactivated`, `trial.started`, plus `flow.published` from the service so API and approval publishes count too. Billing events are emitted by `platform-plan-telemetry.ts`; the entitlement refresh in `autumn-utils.ts` compares the plan before and after and emits `trial.started` or `plan.changed` when it changed, skipping free plans (`FREE`, `FREE_LEGACY`, `APPSUMO`). Platforms are PostHog groups (`groupIdentify`, type `platform`) set on creation and refreshed on plan change. Every event, person and group carries `deployment` (`DeploymentKind`: cloud, self_hosted, dev) because self-hosted instances report into the same project. + +Gotchas: +- Pre-login email-code events are keyed by the identity id; `aliasIdentity` merges that person into the user once the user exists, and `trackForIdentity` in `passwordless-auth.service.ts` switches to the user id as soon as one exists for identity+platform. +- Billing and onboarding events are in `CLOUD_ONLY_TELEMETRY_EVENTS`; the tracked-events catalog on the platform settings page hides groups whose events are all cloud-only, so `billing` never shows on self-hosted. +- In a development environment the web sends nothing to PostHog unless `localStorage.ap_posthog_dev` is `'1'`. +- **The event is `plan.changed`, not `plan.upgraded`** — the entitlement refresh only knows the plan differs, not which way, so a downgrade reaches the same code path. Direction is derived in PostHog from the `previousPlan` and `plan` properties. A trial converting to paid emits **nothing**: it is the same `planId` with the trial simply ended, so the plan-equality check returns early, and no previous trialing state is persisted anywhere to compare against (`trialEndsAt` comes from the live Autumn subscription, `platform_plan` has no trial column, and the customer state cache holds only balances). +- **`flow.published` is emitted once, by `flowService`** — the browser capture in `flow-hooks.tsx` was removed when the service emit landed, because the two fired on exactly the same UI publishes. The service emit also counts publishes no user pressed Publish for: the connection-swap republish in `app-connection.handler.ts` and the git-sync project-release import both dispatch `LOCK_AND_PUBLISH`. +- **A platform group's `name` only leaves Cloud** (`pickPlatformGroupPii`): a self-hosted platform name is usually the customer's company name, the same reason `pickTelemetryPii` exists. The group itself is still created on every edition, so `plan`, `createdAt` and `deployment` stay groupable off Cloud. +- **Google and SAML emit `signed.in` only for a returning user**, in the `else` branch of the `signedUp` check, mirroring the password path where sign-up emits `signed.up` and only a later sign-in emits `signed.in`. Emitting it on every claim made SSO sign-in counts run high against password. + ### Flow Failure Alerts (EE) Email on flow-run failure. First failure per flowVersion per 24h window sends; rest suppressed via Redis counter `flow_fail_count:` (1-day TTL). Personal projects: single owner-only receiver toggle; team projects: any number of receivers. Platform admins can bulk sub/unsub across projects (max 5 concurrent). Receivers stored/compared lowercase. Edition check (`paidEditions`) in service, no plan flag. No Issues feature — email links straight to the run page. EE/Cloud only. diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md index ea6732d5bafa..cb6dbc914e6f 100644 --- a/brain/knowledge/flows-execution/flows.md +++ b/brain/knowledge/flows-execution/flows.md @@ -8,7 +8,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + ### Entities & services - **Flow** — persistent record: status (ENABLED/DISABLED), folderId, publishedVersionId, externalId, operationStatus (NONE/DELETING/ENABLING/DISABLING), ownerId, createdBy (`FlowCreator`: `{type: MCP|AGENT, id}`, null for humans → "AI" badge). -- **FlowVersion** — immutable-once-LOCKED snapshot of the graph. DRAFT is editable; current schemaVersion is `'22'`. Holds trigger (full graph JSONB), connectionIds, agentIds, notes. +- **FlowVersion** — immutable-once-LOCKED snapshot of the graph. DRAFT is editable; current schemaVersion is `'27'` (`LATEST_FLOW_SCHEMA_VERSION`). Holds trigger (full graph JSONB), connectionIds, agentIds, notes. - **Folder** — simple per-project grouping, case-insensitive unique. - Core service: `flow.service.ts`; single controller endpoint `POST /v1/flows/:id`. @@ -24,6 +24,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + - **A CODE step's compiled size is its `packageJson`, not its code — bundling *inlines* `node_modules`, it does not exclude it.** This gets assumed backwards a lot: there is no `node_modules` at runtime precisely *because* esbuild inlines every dependency into the step's `index.js`. Measured on cloud, Aug 2026: a step with **1,870 characters** of user source and `{"pdfkit":"0.14.0","aws-sdk":"2.1531.0","uuid":"9.0.1"}` compiles to **24.13 MB**, of which 24.13 MB is `node_modules` — **21.16 MB of it `aws-sdk` alone**. v2 of that SDK resolves its ~200 service clients by dynamic `require`, so esbuild cannot tree-shake it and inlines all of them; `@aws-sdk/client-*` (v3) would be a few hundred KB. Fleet-wide there were 13,918 compiled steps totalling 1.4 GB, **61 of them over 10 MB**, and per *flow* the totals reach **190.7 MB across 40 code steps**. That per-flow number is the one that matters operationally, because `flowBundleStore.publish` holds a flow's entire compiled output in memory at once (three copies — see the OOM gotcha on [[workers]]). To find the offenders: esbuild leaves `// node_modules//…` markers in the output, so you can attribute bytes per package by summing the lines between markers. - **Step output nesting (schema v21+)**: every step output is wrapped as `{ output, error? }`; expressions must use the `['output']` accessor. The v20→v21 migration rewrites existing expressions via `expression-rewriter`. - **Migrations run on every single-version read, including the worker's own `getFlowVersion`** (`findOne` → `flowVersionMigrationService.migrate`). So no run can execute an unmigrated version, and restoring an old version migrates it forward too. The listing path is the exception: `flowVersionService.list()` paginates the repository directly and hands back whatever schema version the row holds, so a caller reading the version history can see unmigrated rows. The result is persisted, then short-circuits at `LATEST_FLOW_SCHEMA_VERSION`. The old version is saved to `backupFiles[oldSchemaVersion]` first — **that** is what a restore reads, so there's no need to keep an old code path alive "just in case". Nothing restores on its own: `flowVersionBackupService.get()` has no callers, so recovering a version is a deliberate manual step. A migration that throws pages on-call (`FLOW_MIGRATION_FAILED`). +- **Template migrations run on a flow version that is not stored.** `migrateFlowVersionTemplate` (the `IMPORT_FLOW` pre-validation and the template controllers) builds a stand-in version with `flowId: ''` and passes no `MigrationContext`, so there is no project or platform either. A migration that looks anything up by flow id must treat "not found" as "nothing stored", never throw: the v23 piece-upgrade audit event used `findOneByOrFail`, which turned every import of an old template holding a register-listed piece into a 500 (the event-destinations "Generate handler flow" hit it with webhook `0.1.33`). - **Continue on Failure**: CODE/PIECE steps with `continueOnFailure.value: true` carry `onSuccess`/`onFailure` sub-trees under `settings.errorHandlingOptions.continueOnFailureBranches`. - **`addActionUtils.clone()` is the single chokepoint for renaming copied steps and rewriting their `{{ }}` references** — paste, duplicate step, and duplicate branch all route through it. It walks the **whole `settings`** deliberately, not just `settings.input`: router conditions live at `settings.branches[].conditions[][].firstValue/.secondValue` and loop expressions at `settings.items`, and an `'input' in settings` guard silently skipped both (GIT-1075). Two things to respect when touching it: `settings.sourceCode` is excluded on purpose (it holds a user program, and a literal `{{ … }}` in a code step was being rewritten), and the remap must stay a **single pass** over the name map — applying one rename at a time rewrites its own output whenever a copied step's new name equals another copied step's old name, which happens on cross-flow paste into a flow that lacks the clipboard's names. - **Paste order follows selection order, not flow order.** `_getActionsForCopy`'s `.sort((a, b) => allSteps.indexOf(a) - ...)` compares deep clones, so `indexOf` is always `-1` and the sort is a no-op. Harmless for a single step; it decides the chaining order for a multi-step paste. diff --git a/bun.lock b/bun.lock index 2466e08667f3..a1b5c651ddae 100644 --- a/bun.lock +++ b/bun.lock @@ -165,7 +165,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.177.0", + "version": "0.178.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -3830,7 +3830,7 @@ }, "packages/pieces/community/google-drive": { "name": "@activepieces/piece-google-drive", - "version": "0.10.0", + "version": "0.11.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 303ade598a50..83ebd6e24154 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.177.0", + "version": "0.178.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index 7f0ca282d4ea..de495f92ef65 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -28,6 +28,45 @@ type SignInSubmitted = { method: 'email' } +type OnboardingCompleted = { + userId: UserId + platformId: string +} + +type PlanChange = { + platformId: string + plan: string + previousPlan?: string +} + +type CheckoutStarted = { + platformId: string + plan: string +} + +type TrialStarted = { + platformId: string + plan: string + trialEndsAt: string +} + +type InviteSent = { + platformId: string + type: 'platform' | 'project' + role?: string +} + +type InviteAccepted = { + platformId: string + type: 'platform' | 'project' +} + +type SalesHandoffClicked = { + featureKey?: string + plan?: string + surface: string +} + type SignInFailed = { errorCode: string } @@ -119,6 +158,15 @@ export enum TelemetryEventName { PIECE_SELECTOR_SEARCH = 'piece.selector.search', MCP_TOOL_CALLED = 'mcp.tool.called', MCP_SERVER_CONNECTED = 'mcp.server.connected', + ONBOARDING_COMPLETED = 'onboarding.completed', + CHECKOUT_STARTED = 'checkout.started', + PLAN_CHANGED = 'plan.changed', + PLAN_CANCELLED = 'plan.cancelled', + PLAN_REACTIVATED = 'plan.reactivated', + TRIAL_STARTED = 'trial.started', + INVITE_SENT = 'invite.sent', + INVITE_ACCEPTED = 'invite.accepted', + SALES_HANDOFF_CLICKED = 'sales.handoff.clicked', } export type TelemetryEvent = @@ -142,8 +190,24 @@ export type TelemetryEvent = | BaseTelemetryEvent | BaseTelemetryEvent | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new Set([ + TelemetryEventName.ONBOARDING_COMPLETED, + TelemetryEventName.CHECKOUT_STARTED, + TelemetryEventName.PLAN_CHANGED, + TelemetryEventName.PLAN_CANCELLED, + TelemetryEventName.PLAN_REACTIVATED, + TelemetryEventName.TRIAL_STARTED, + TelemetryEventName.SALES_HANDOFF_CLICKED, TelemetryEventName.SIGNED_UP, TelemetryEventName.SIGNED_IN, TelemetryEventName.SIGN_UP_SUBMITTED, @@ -160,3 +224,9 @@ export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new ]) export const isCloudOnlyTelemetryEvent = (name: TelemetryEventName): boolean => CLOUD_ONLY_TELEMETRY_EVENTS.has(name) + +export enum DeploymentKind { + CLOUD = 'cloud', + SELF_HOSTED = 'self_hosted', + DEV = 'dev', +} diff --git a/packages/core/utils/src/lib/ai-provider-health.ts b/packages/core/utils/src/lib/ai-provider-health.ts index 8892e2c2113c..225882fe013e 100644 --- a/packages/core/utils/src/lib/ai-provider-health.ts +++ b/packages/core/utils/src/lib/ai-provider-health.ts @@ -138,7 +138,7 @@ function isNil(value: T | null | undefined): value is null | undefined { const MAX_OBSERVED_BODY_LENGTH = 2000 const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i] -const TRANSIENT_ERROR_PATTERN = /\b(429|5\d\d)\b|rate.?limit|timeout|timed out|temporarily|try again|econnreset|etimedout|socket hang up|service unavailable/i +const TRANSIENT_ERROR_PATTERN = /\b(429|5\d\d)\b|rate.?limit|timeout|timed out|temporarily|econnreset|etimedout|socket hang up|service unavailable/i const BILLING_BODY_PATTERN = /insufficient_quota|credit[_ ]balance|billing_hard_limit_reached|billing|\bcredits?\b|out of funds|payment required/i diff --git a/packages/pieces/community/google-drive/package.json b/packages/pieces/community/google-drive/package.json index efb3b0536875..fc4ec7a22f40 100644 --- a/packages/pieces/community/google-drive/package.json +++ b/packages/pieces/community/google-drive/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-drive", - "version": "0.10.0", + "version": "0.11.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/google-drive/src/index.ts b/packages/pieces/community/google-drive/src/index.ts index cd09d386eba6..960c97af2480 100644 --- a/packages/pieces/community/google-drive/src/index.ts +++ b/packages/pieces/community/google-drive/src/index.ts @@ -66,7 +66,7 @@ import { driveGetAbout } from './lib/action/drive-get-about'; export { googleDriveAuth, getAccessToken, GoogleDriveAuthValue, createGoogleClient } from './lib/auth'; export const googleDrive = createPiece({ - minimumSupportedRelease: '0.86.4', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/google-drive.png', categories: [PieceCategory.CONTENT_AND_FILES], displayName: 'Google Drive', diff --git a/packages/pieces/community/google-drive/src/lib/action/add-permission.action.ts b/packages/pieces/community/google-drive/src/lib/action/add-permission.action.ts index ccd89e523ea5..ce4c99809311 100644 --- a/packages/pieces/community/google-drive/src/lib/action/add-permission.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/add-permission.action.ts @@ -8,37 +8,39 @@ export const addPermission = createAction({ auth: googleDriveAuth, name: 'update_permissions', classification: 'WRITE', - description: 'Update permissions for a file or folder', + description: 'Give a person a role on a file or folder by email.', audience: 'human', aiMetadata: { description: 'Grants a specified role (reader, commenter, writer, fileOrganizer, or organizer) on a Drive file or folder to a user identified by email, optionally sending a notification email. Use to share a resource with a person. Requires the file/folder ID and target email. Not idempotent: each call creates a new permission grant.', idempotent: false }, - displayName: 'Update permissions', + displayName: 'Share File or Folder', props: { fileId: Property.ShortText({ displayName: 'File or Folder ID', - description: 'The ID of the file or folder to update permissions for', + description: "The ID from the item's Drive URL or an earlier step.", required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), user_email: Property.ShortText({ - displayName: 'User email', - description: 'The email address of the user to update permissions for', + displayName: 'User Email', + description: 'The person who receives the access.', required: true, + placeholder: 'name@example.com', }), permission_name : Property.StaticDropdown({ displayName: 'Role', - description: 'The role to grant to user. See more at: https://developers.google.com/drive/api/guides/ref-roles', + description: 'What the person can do. Manager roles apply to shared drives only.', required: true, options: { options: [ { - label: 'Organizer', + label: 'Manager', value: 'organizer', }, { - label: 'File Organizer', + label: 'Content Manager', value: 'fileOrganizer', }, { - label: 'Writer', + label: 'Editor', value: 'writer', }, { @@ -46,19 +48,19 @@ export const addPermission = createAction({ value: 'commenter', }, { - label: 'Reader', + label: 'Viewer', value: 'reader', }, ] } }), - include_team_drives: common.properties.include_team_drives, send_invitation_email: Property.Checkbox({ - displayName: 'Send invitation email', - description: 'Send an email to the user to notify them of the new permissions', + displayName: 'Send Invitation Email', + description: 'Email the person that they now have access.', required: true, }), + include_team_drives: common.properties.include_team_drives, }, outputSchema: updatePermissionsActionOutputSchema, diff --git a/packages/pieces/community/google-drive/src/lib/action/create-new-folder.ts b/packages/pieces/community/google-drive/src/lib/action/create-new-folder.ts index b37563e3a1ec..56b973646f93 100644 --- a/packages/pieces/community/google-drive/src/lib/action/create-new-folder.ts +++ b/packages/pieces/community/google-drive/src/lib/action/create-new-folder.ts @@ -8,15 +8,15 @@ export const googleDriveCreateNewFolder = createAction({ auth: googleDriveAuth, name: 'create_new_gdrive_folder', classification: 'WRITE', - description: 'Create a new empty folder in your Google Drive', + description: 'Create an empty folder in Google Drive.', audience: 'human', aiMetadata: { description: 'Creates a new empty folder in Google Drive, optionally nested under a parent folder. Use to set up a destination before uploading or moving files. Not idempotent: each call creates a distinct folder even with the same name (Drive permits duplicate folder names).', idempotent: false }, - displayName: 'Create new folder', + displayName: 'Create Folder', props: { folderName: Property.ShortText({ - displayName: 'Folder name', - description: 'The name of the new folder', + displayName: 'Folder Name', required: true, + placeholder: 'Invoices 2026', }), parentFolder: common.properties.parentFolder, include_team_drives: common.properties.include_team_drives, diff --git a/packages/pieces/community/google-drive/src/lib/action/create-new-text-file.ts b/packages/pieces/community/google-drive/src/lib/action/create-new-text-file.ts index df8d577a4a22..e1ef9d2d76cb 100644 --- a/packages/pieces/community/google-drive/src/lib/action/create-new-text-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/create-new-text-file.ts @@ -8,30 +8,31 @@ export const googleDriveCreateNewTextFile = createAction({ auth: googleDriveAuth, name: 'create_new_gdrive_file', classification: 'WRITE', - description: 'Create a new text file in your Google Drive from text', + description: 'Create a text, CSV or XML file from text you enter.', audience: 'human', aiMetadata: { description: 'Creates a new file in Google Drive from inline text content as plain text, CSV, or XML, optionally inside a parent folder. Use when an agent has generated text it needs to persist as a Drive file. Not idempotent: each call creates a new file.', idempotent: false }, - displayName: 'Create new file', + displayName: 'Create File from Text', props: { fileName: Property.ShortText({ - displayName: 'File name', - description: 'The name of the new text file', + displayName: 'File Name', + description: 'Include the extension, for example report.csv.', required: true, + placeholder: 'report.txt', }), text: Property.LongText({ - displayName: 'Text', - description: 'The text content to add to file', + displayName: 'Content', + description: 'The text written into the file.', required: true, }), fileType: Property.StaticDropdown({ - displayName: 'Content type', - description: 'Select file type', + displayName: 'File Type', + description: 'Plain text, CSV or XML. Match the extension in the file name.', required: true, defaultValue: 'text/plain', options: { options: [ { - label: 'Text', + label: 'Plain Text (TXT)', value: 'text/plain', }, { diff --git a/packages/pieces/community/google-drive/src/lib/action/delete-file.ts b/packages/pieces/community/google-drive/src/lib/action/delete-file.ts index d069c2158c21..7c381478b6e3 100644 --- a/packages/pieces/community/google-drive/src/lib/action/delete-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/delete-file.ts @@ -7,15 +7,16 @@ export const googleDriveDeleteFile = createAction({ auth: googleDriveAuth, name: 'delete_gdrive_file', classification: 'DESTRUCTIVE', - description: 'Delete permanently a file from your Google Drive', + description: 'Permanently delete a file, skipping the bin.', audience: 'human', aiMetadata: { description: 'Permanently deletes a file from Google Drive by its ID, bypassing the trash and making it unrecoverable. Use only when permanent removal is intended; prefer Trash file for reversible deletion. Requires the file ID. Not idempotent: a repeat call fails because the file no longer exists.', idempotent: false }, - displayName: 'Delete file', + displayName: 'Delete File', props: { fileId: Property.ShortText({ displayName: 'File ID', - description: 'The ID of the file to delete', + description: 'The ID of the file to delete. This cannot be undone.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), include_team_drives: common.properties.include_team_drives, }, diff --git a/packages/pieces/community/google-drive/src/lib/action/delete-permission.action.ts b/packages/pieces/community/google-drive/src/lib/action/delete-permission.action.ts index 2d452904c785..b4e3304d9df7 100644 --- a/packages/pieces/community/google-drive/src/lib/action/delete-permission.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/delete-permission.action.ts @@ -7,37 +7,39 @@ export const deletePermission = createAction({ auth: googleDriveAuth, name: 'delete_permissions', classification: 'DESTRUCTIVE', - description: 'Removes a role from an user for a file or folder', + description: "Remove a person's role from a file or folder.", audience: 'human', aiMetadata: { description: 'Revokes a specific role from a user (matched by email and role) on a Drive file or folder. Use to unshare or downgrade access for someone. Requires the file/folder ID, the user email, and the role to remove. Idempotent: if no matching permission exists, the call is a no-op.', idempotent: true }, - displayName: 'Delete permissions', + displayName: 'Remove Access', props: { fileId: Property.ShortText({ displayName: 'File or Folder ID', - description: 'The ID of the file or folder to update permissions for', + description: "The ID from the item's Drive URL or an earlier step.", required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), user_email: Property.ShortText({ - displayName: 'User email', - description: 'The email address of the user to update permissions for', + displayName: 'User Email', + description: 'The person whose access is removed.', required: true, - }), + placeholder: 'name@example.com', + }), permission_name : Property.StaticDropdown({ displayName: 'Role', - description: 'The role to remove from user.', + description: 'The role to remove. Other roles the person has stay.', required: true, options: { options: [ { - label: 'Organizer', + label: 'Manager', value: 'organizer', }, { - label: 'File Organizer', + label: 'Content Manager', value: 'fileOrganizer', }, { - label: 'Writer', + label: 'Editor', value: 'writer', }, { @@ -45,10 +47,10 @@ export const deletePermission = createAction({ value: 'commenter', }, { - label: 'Reader', + label: 'Viewer', value: 'reader', }, - + ] } }), @@ -63,7 +65,7 @@ export const deletePermission = createAction({ const response_permissions_list = await drive.permissions.list({ fileId: fileId, fields: 'permissions(id, emailAddress, role)', - + supportsAllDrives: true, }); if (response_permissions_list.data.permissions) { @@ -73,6 +75,7 @@ export const deletePermission = createAction({ await drive.permissions.delete({ fileId: fileId, permissionId: permission.id ? permission.id : '', + supportsAllDrives: true, }); return {removed: true, message: 'Permission removed'}; } diff --git a/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts b/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts index 0b351a9c6f1b..c609964365ff 100644 --- a/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts +++ b/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts @@ -71,11 +71,6 @@ const NATIVE_TYPES: Record = { }, }; -const encryptionMethodDescription = ` -- ZipCrypto: Legacy encryption method with wide compatibility (not recommended for sensitive data) -- AES-256: Modern encryption with strong security (may not be supported by older zip clients) -`; - interface ZipFolderEntry { relativePath: string; fileId: string; @@ -457,7 +452,7 @@ export const driveExportFolderAsZip = createAction({ classification: 'READ', displayName: 'Export Folder as Zip', description: - 'Recursively export a Google Drive folder (with all subfolders) as a single zip file.', + 'Download a folder and all its subfolders as one zip file.', audience: 'human', aiMetadata: { description: @@ -465,14 +460,10 @@ export const driveExportFolderAsZip = createAction({ idempotent: true, }, props: { - duplicatePathWarning: Property.MarkDown({ - value: - 'Zip paths mirror the Drive folder exactly, with no renaming. The action fails before downloading anything if: a file and a folder share a name in the same Drive folder, two items share a name, or a Google Doc/Sheet/Slides export lands on a name that already exists (e.g. a Sheet named "Report" exported as PDF alongside an existing "Report.pdf"); or an included item\'s name contains "/" or "\\", or is exactly "." or ".." (not usable as a zip path segment). Rename the conflicting or unsafe item in Drive and re-run.', - variant: MarkdownVariant.WARNING, - }), folderId: Property.Dropdown({ displayName: 'Folder', - description: 'The Drive folder to export (including all subfolders).', + description: + 'Everything inside it, including subfolders, goes into the zip.', required: true, auth: googleDriveAuth, refreshers: ['includeTeamDrives'], @@ -486,62 +477,72 @@ export const driveExportFolderAsZip = createAction({ }), includeTeamDrives: Property.Checkbox({ displayName: 'Include Team Drives', + description: 'Also include shared drives, not only My Drive.', required: false, defaultValue: false, }), googleDocsFormat: Property.StaticDropdown({ - displayName: 'Google Docs', - description: 'How to include native Google Docs found in the folder.', + displayName: 'Google Docs As', + description: 'Convert Docs to Word or PDF, or leave them out.', required: true, defaultValue: 'docx', options: { options: [ { label: 'Word (DOCX)', value: 'docx' }, { label: 'PDF', value: 'pdf' }, - { label: 'Skip', value: 'skip' }, + { label: 'Leave Out', value: 'skip' }, ], }, }), googleSheetsFormat: Property.StaticDropdown({ - displayName: 'Google Sheets', - description: 'How to include native Google Sheets found in the folder.', + displayName: 'Google Sheets As', + description: 'Convert Sheets to Excel or PDF, or leave them out.', required: true, defaultValue: 'xlsx', options: { options: [ { label: 'Excel (XLSX)', value: 'xlsx' }, { label: 'PDF', value: 'pdf' }, - { label: 'Skip', value: 'skip' }, + { label: 'Leave Out', value: 'skip' }, ], }, }), googleSlidesFormat: Property.StaticDropdown({ - displayName: 'Google Slides', - description: 'How to include native Google Slides found in the folder.', + displayName: 'Google Slides As', + description: 'Convert Slides to PowerPoint or PDF, or leave them out.', required: true, defaultValue: 'pptx', options: { options: [ { label: 'PowerPoint (PPTX)', value: 'pptx' }, { label: 'PDF', value: 'pdf' }, - { label: 'Skip', value: 'skip' }, + { label: 'Leave Out', value: 'skip' }, ], }, }), + duplicatePathWarning: Property.MarkDown({ + value: + 'Zip paths copy the Drive names. The export stops before downloading anything if two items in one folder would share a path, for example a Sheet named "Report" exported as PDF next to an existing Report.pdf, or if a name contains / or \\ or is "." or "..". Rename the item in Drive and run again.', + variant: MarkdownVariant.WARNING, + }), outputFileName: Property.ShortText({ - displayName: 'Output Zip File Name', + displayName: 'Zip File Name', + description: 'Include the .zip extension.', required: true, defaultValue: 'export.zip', + placeholder: 'export.zip', }), usePassword: Property.Checkbox({ - displayName: 'Use password', - description: 'Enable password protection for the zip file', + displayName: 'Use Password', + description: 'Encrypt the zip so a password is needed to open it.', required: false, defaultValue: false, + advanced: true, }), passwordOptions: Property.DynamicProperties({ - displayName: 'Password options', + displayName: 'Password Options', required: false, + advanced: true, auth: PieceAuth.None(), refreshers: ['usePassword'], props: async ({ usePassword }) => { @@ -552,18 +553,20 @@ export const driveExportFolderAsZip = createAction({ const fields = { password: Property.ShortText({ displayName: 'Password', + description: 'Needed to open the zip.', required: true, }), encryptionMethod: Property.StaticDropdown({ displayName: 'Encryption Method', - description: encryptionMethodDescription, + description: + 'ZipCrypto opens everywhere; AES-256 is stronger but needs a modern unzip app.', required: true, defaultValue: 'zipcrypto', options: { disabled: false, options: [ - { label: 'ZipCrypto (Most Compatible)', value: 'zipcrypto' }, - { label: 'AES-256 (Stronger Security)', value: 'aes-256' }, + { label: 'ZipCrypto', value: 'zipcrypto' }, + { label: 'AES-256', value: 'aes-256' }, ], }, }), diff --git a/packages/pieces/community/google-drive/src/lib/action/duplicate-file.action.ts b/packages/pieces/community/google-drive/src/lib/action/duplicate-file.action.ts index 3efd8f65d13b..fc8c516146a9 100644 --- a/packages/pieces/community/google-drive/src/lib/action/duplicate-file.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/duplicate-file.action.ts @@ -9,29 +9,32 @@ export const duplicateFileAction = createAction({ auth: googleDriveAuth, name: 'duplicate_file', classification: 'WRITE', - description: 'Duplicate a file from Google Drive. Returns the new file ID.', + description: 'Copy a file into a folder, optionally as a Google Doc or Sheet.', audience: 'human', aiMetadata: { description: 'Copies an existing Drive file into a target folder under a new name, optionally converting it to a Google Sheet or Google Doc. Use to clone a file or create an editable Google-format copy. Requires the source file ID and destination folder ID. Not idempotent: each call creates a new copy.', idempotent: false }, props: { fileId: Property.ShortText({ displayName: 'File ID', - description: 'The ID of the file to duplicate', + description: 'The ID of the file to copy.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), name: Property.ShortText({ - displayName: 'Name', - description: 'The name of the new file', + displayName: 'New File Name', + description: 'Name of the copy.', required: true, + placeholder: 'Report (copy)', }), folderId: Property.ShortText({ - displayName: 'Folder ID', - description: 'The ID of the folder where the file will be duplicated', + displayName: 'Destination Folder ID', + description: 'The ID of the folder that receives the copy.', required: true, }), mimeType: Property.StaticDropdown({ - displayName: 'Duplicate as', - description: 'If left unselected the file will be duplicated as it is', + displayName: 'Convert To', + description: 'Empty keeps the original format.', required: false, + advanced: true, options: { options: [ { diff --git a/packages/pieces/community/google-drive/src/lib/action/get-file-by-id.ts b/packages/pieces/community/google-drive/src/lib/action/get-file-by-id.ts index bff02b5c914b..8f5b4e025902 100644 --- a/packages/pieces/community/google-drive/src/lib/action/get-file-by-id.ts +++ b/packages/pieces/community/google-drive/src/lib/action/get-file-by-id.ts @@ -9,14 +9,15 @@ export const googleDriveGetResourceById = createAction({ name: 'get-file-or-folder-by-id', classification: 'READ', displayName: 'Get File Information', - description: 'Get a file folder for files/sub-folders', + description: 'Get the details of a file or folder by its ID.', audience: 'human', aiMetadata: { description: 'Fetches metadata for a single file or folder in Google Drive by its exact ID (name, MIME type, parents, etc.). Use when an agent already has a file/folder ID and needs its details. Read-only and idempotent. Requires the resource ID, not a name or path.', idempotent: true }, props: { id: Property.ShortText({ - displayName: 'File / Folder Id', - description: 'The Id of the file/folder to search for.', + displayName: 'File or Folder ID', + description: "The ID from the item's Drive URL or an earlier step.", required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), include_team_drives: common.properties.include_team_drives, }, diff --git a/packages/pieces/community/google-drive/src/lib/action/list-files.action.ts b/packages/pieces/community/google-drive/src/lib/action/list-files.action.ts index b46d73800c3b..ca5008e0aff6 100644 --- a/packages/pieces/community/google-drive/src/lib/action/list-files.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/list-files.action.ts @@ -17,38 +17,41 @@ export const googleDriveListFiles = createAction({ auth: googleDriveAuth, name: 'list-files', classification: 'SEARCH', - displayName: 'List files', - description: 'List files from a Google Drive folder', + displayName: 'List Files', + description: 'List the files and folders inside a folder.', audience: 'human', aiMetadata: { description: 'Lists files and subfolders inside a given Drive folder, with optional recursion to a chosen depth and optional download of each file\'s content. Use to enumerate the contents of a known folder ID. Read-only and idempotent. Requires the folder ID, not a name.', idempotent: true }, props: { folderId: Property.ShortText({ displayName: 'Folder ID', - description: 'Folder ID coming from | New Folder -> id | (or any other source)', + description: 'The ID after /folders/ in the folder URL, or from an earlier step.', required: true, - }), - include_team_drives: common.properties.include_team_drives, - - includeTrashed: Property.Checkbox({ - displayName: 'Include Trashed', - description: 'Include new files that have been trashed.', - required: false, - defaultValue: false + placeholder: '1aMEtTqIYn5651wdK7WLxaK_SDim4mvXW', }), depthLevel: Property.Number({ - displayName: 'Depth Level', - description: 'How many levels deep to search for files. 1 = current folder only, 2 = current + next level, etc.', + displayName: 'Folder Depth', + description: '1 lists only this folder; 2 adds its subfolders, and so on.', required: false, defaultValue: 1 }), downloadFiles: Property.Checkbox({ displayName: 'Download Files', - description: 'Download all file contents in a list', + description: 'Also download every file. Slow for large folders.', required: false, defaultValue: false }), + + include_team_drives: common.properties.include_team_drives, + + includeTrashed: Property.Checkbox({ + displayName: 'Include Trashed', + description: 'Also list files that are in the bin.', + required: false, + defaultValue: false, + advanced: true + }), }, outputSchema: listFilesActionOutputSchema, async run(context) { diff --git a/packages/pieces/community/google-drive/src/lib/action/move-file.ts b/packages/pieces/community/google-drive/src/lib/action/move-file.ts index 6eeb49a97e40..daeb8757c671 100644 --- a/packages/pieces/community/google-drive/src/lib/action/move-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/move-file.ts @@ -9,17 +9,21 @@ export const moveFileAction = createAction({ name: 'google-drive-move-file', classification: 'WRITE', displayName: 'Move File', - description: 'Moves a file from one folder to another.', + description: 'Move a file into another folder.', audience: 'human', aiMetadata: { description: 'Relocates a Drive file to a target folder, detaching it from its current parents and attaching it to the new one. Use to reorganize a file once you know its ID and the destination folder ID. Idempotent: repeating with the same destination leaves the file in the same folder.', idempotent: true }, props: { fileId: Property.ShortText({ displayName: 'File ID', - description: 'You can use **Search Folder/File** action to retrieve ID.', + description: 'Use the ID from a trigger or the Find File or Folder action.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', + }), + folderId: common.parentFolderDropdown({ + displayName: 'Destination Folder', + description: 'The folder to move the file into. Type to search by folder name.', }), include_team_drives: common.properties.include_team_drives, - folderId: common.properties.parentFolder, }, outputSchema: googleDriveMoveFileActionOutputSchema, async run(context) { diff --git a/packages/pieces/community/google-drive/src/lib/action/read-file.ts b/packages/pieces/community/google-drive/src/lib/action/read-file.ts index 7f68dff36462..e19a66612ca8 100644 --- a/packages/pieces/community/google-drive/src/lib/action/read-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/read-file.ts @@ -7,19 +7,22 @@ export const readFile = createAction({ auth: googleDriveAuth, name: 'read-file', classification: 'READ', - displayName: 'Read File Content', - description: 'Read a selected file from google drive file', + displayName: 'Download File', + description: 'Download a file. Google Docs, Sheets and Slides become Office files.', audience: 'human', aiMetadata: { description: 'Downloads the content of a Drive file by its file ID and returns it as a usable file reference. Use to retrieve a file an agent already knows the ID of (e.g. from a trigger or search). Read-only and idempotent. Requires the exact file ID, not a name.', idempotent: true }, props: { fileId: Property.ShortText({ displayName: 'File ID', - description: 'File ID coming from | New File -> id |', + description: 'Use the ID from a trigger or the Find File or Folder action.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), fileName: Property.ShortText({ - displayName: 'Destination File name', + displayName: 'File Name', + description: 'Name for the saved file. Empty uses the file ID plus its extension.', required: false, + placeholder: 'report.pdf', }), }, outputSchema: readFileActionOutputSchema, diff --git a/packages/pieces/community/google-drive/src/lib/action/save-file-as-pdf.action.ts b/packages/pieces/community/google-drive/src/lib/action/save-file-as-pdf.action.ts index 21a55bb1cbca..a6889c948cef 100644 --- a/packages/pieces/community/google-drive/src/lib/action/save-file-as-pdf.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/save-file-as-pdf.action.ts @@ -12,24 +12,26 @@ export const saveFileAsPdf = createAction({ auth: googleDriveAuth, name: 'save_file_as_pdf', classification: 'WRITE', - description: 'Save a document as PDF in a Google Drive folder', + description: 'Export a Google Doc, Sheet or Slides as a PDF into a folder.', audience: 'human', aiMetadata: { description: 'Exports a Google document to PDF and saves it as a new file in a target Drive folder. Use to produce a PDF rendition of a Google Doc/Sheet/Slides for sharing or archival. Requires the source document ID and destination folder ID. Not idempotent: each call creates a new PDF file.', idempotent: false }, props: { documentId: Property.ShortText({ displayName: 'Document ID', - description: 'The ID of the document to export', + description: 'The ID of the Google Doc, Sheet or Slides to export.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), folderId: Property.ShortText({ - displayName: 'Folder ID', - description: 'The ID of the folder where the file will be exported', + displayName: 'Destination Folder ID', + description: 'The ID of the folder that receives the PDF.', required: true, }), name: Property.ShortText({ - displayName: 'Name', - description: 'The name of the new file (do not include the extension)', + displayName: 'File Name', + description: 'Without the extension; .pdf is added.', required: true, + placeholder: 'Quarterly report', }), include_team_drives: common.properties.include_team_drives, }, diff --git a/packages/pieces/community/google-drive/src/lib/action/search-folder-or-file.action.ts b/packages/pieces/community/google-drive/src/lib/action/search-folder-or-file.action.ts index db4b0489c268..a6c419909064 100644 --- a/packages/pieces/community/google-drive/src/lib/action/search-folder-or-file.action.ts +++ b/packages/pieces/community/google-drive/src/lib/action/search-folder-or-file.action.ts @@ -2,33 +2,34 @@ import { googleDriveAuth, createGoogleClient } from '../auth'; import { Property, createAction } from '@activepieces/pieces-framework'; import { drive as googleDrive } from '@googleapis/drive'; import { common } from '../common'; +import { buildDriveSearchQuery } from '../common/search-query'; import { searchFolderActionOutputSchema } from '../output-schemas'; export const googleDriveSearchFolder = createAction({ auth: googleDriveAuth, name: 'search-folder', classification: 'SEARCH', - displayName: 'Search', - description: 'Search a Google Drive folder for files/sub-folders', + displayName: 'Find File or Folder', + description: 'Find files or folders by name, content or type.', audience: 'human', aiMetadata: { description: 'Searches Google Drive for files or folders matching a name, full-text, or MIME-type query, optionally scoped to a parent folder and filtered to files or folders only. Use to resolve a file/folder ID from a human-readable name before acting on it. Read-only and idempotent.', idempotent: true }, props: { queryTerm: Property.StaticDropdown({ - displayName: 'Query Term', - description: 'The Query term or field of file/folder to search upon.', + displayName: 'Search By', + description: 'Match the file name, the text inside it, or its type.', defaultValue: 'name', options: { options: [ - { label: 'File name', value: 'name' }, - { label: 'Full text search', value: 'fullText' }, - { label: 'Content type', value: 'mimeType' }, + { label: 'File Name', value: 'name' }, + { label: 'Text Inside the File', value: 'fullText' }, + { label: 'File Type', value: 'mimeType' }, ], }, required: true, }), operator: Property.StaticDropdown({ - displayName: 'Operator', - description: 'The operator to create criteria.', + displayName: 'Match', + description: 'Contains finds partial matches; Equals needs the exact value.', required: true, options: { options: [ @@ -39,24 +40,28 @@ export const googleDriveSearchFolder = createAction({ defaultValue: 'contains', }), query: Property.ShortText({ - displayName: 'Value', - description: 'Value of the field of file/folder to search for.', + displayName: 'Search Text', + description: 'The name, text or type to look for.', required: true, + placeholder: 'Quarterly report', }), type: Property.StaticDropdown({ - displayName: 'File Type', - description: '(Optional) Choose between files and folders.', + displayName: 'Show', + description: 'Return everything, only files or only folders.', required: false, options: { options: [ - { label: 'All', value: 'all' }, - { label: 'Files', value: 'file' }, - { label: 'Folders', value: 'folder' }, + { label: 'Files and Folders', value: 'all' }, + { label: 'Files Only', value: 'file' }, + { label: 'Folders Only', value: 'folder' }, ], }, defaultValue: 'all', }), - parentFolder: common.properties.parentFolder, + parentFolder: common.parentFolderDropdown({ + displayName: 'Search in Folder', + description: 'Leave empty to search all of Drive. Type to search by folder name.', + }), include_team_drives: common.properties.include_team_drives, }, outputSchema: searchFolderActionOutputSchema, @@ -64,24 +69,13 @@ export const googleDriveSearchFolder = createAction({ const authClient = await createGoogleClient(context.auth); const drive = googleDrive({ version: 'v3', auth: authClient }); - const operator = context.propsValue.operator ?? 'contains'; - const queryTerm = context.propsValue.queryTerm ?? 'name'; - let finalQuery = `${queryTerm} ${operator} '${context.propsValue.query}'`; - if (context.propsValue.parentFolder) { - finalQuery = `${finalQuery} and '${context.propsValue.parentFolder}' in parents`; - } - - const type = context.propsValue.type ?? 'all'; - switch (type) { - case 'file': - finalQuery = `${finalQuery} and mimeType!='application/vnd.google-apps.folder'`; - break; - case 'folder': - finalQuery = `${finalQuery} and mimeType='application/vnd.google-apps.folder'`; - break; - default: - break; - } + const finalQuery = buildDriveSearchQuery({ + queryTerm: context.propsValue.queryTerm ?? 'name', + operator: context.propsValue.operator ?? 'contains', + query: context.propsValue.query, + parentFolder: context.propsValue.parentFolder, + type: context.propsValue.type ?? 'all', + }); const allFiles: any[] = []; let pageToken: string | undefined = undefined; diff --git a/packages/pieces/community/google-drive/src/lib/action/send-to-trash.ts b/packages/pieces/community/google-drive/src/lib/action/send-to-trash.ts index ee69bdab1513..1865a656d9a2 100644 --- a/packages/pieces/community/google-drive/src/lib/action/send-to-trash.ts +++ b/packages/pieces/community/google-drive/src/lib/action/send-to-trash.ts @@ -8,15 +8,16 @@ export const googleDriveTrashFile = createAction({ auth: googleDriveAuth, name: 'trash_gdrive_file', classification: 'DESTRUCTIVE', - description: 'Move a file to the trash in your Google Drive', + description: 'Move a file to the bin, where it can be restored.', audience: 'human', aiMetadata: { description: 'Moves a Drive file to the trash by its ID, a reversible deletion that can be restored from Drive. Use for safe removal instead of permanent deletion. Requires the file ID. Idempotent: re-trashing an already-trashed file leaves it in the same state.', idempotent: true }, - displayName: 'Trash file', + displayName: 'Move File to Trash', props: { fileId: Property.ShortText({ displayName: 'File ID', - description: 'The ID of the file to trash', + description: 'The ID of the file to trash.', required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), include_team_drives: common.properties.include_team_drives, }, diff --git a/packages/pieces/community/google-drive/src/lib/action/set-public-access.ts b/packages/pieces/community/google-drive/src/lib/action/set-public-access.ts index e3acbf3fc1c6..818b8d5977df 100644 --- a/packages/pieces/community/google-drive/src/lib/action/set-public-access.ts +++ b/packages/pieces/community/google-drive/src/lib/action/set-public-access.ts @@ -8,22 +8,23 @@ export const setPublicAccess = createAction({ auth: googleDriveAuth, name: 'set_public_access', classification: 'WRITE', - description: 'Set public access for a file or folder', + description: 'Let anyone with the link open a file or folder.', audience: 'human', aiMetadata: { description: 'Makes a Drive file or folder accessible to anyone with the link at the chosen role (reader, commenter, or writer) and returns its shareable view/download URL. Use to publish a resource publicly. Requires the file/folder ID. Not idempotent: each call adds a new anyone-with-link permission.', idempotent: false }, - displayName: 'Set public access', + displayName: 'Set Public Access', props: { fileId: Property.ShortText({ displayName: 'File or Folder ID', - description: 'The ID of the file or folder to update permissions for', + description: "The ID from the item's Drive URL or an earlier step.", required: true, + placeholder: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', }), role: Property.StaticDropdown({ displayName: 'Role', - description: 'The role to assign for public access', + description: 'What anyone with the link can do.', options: { options: [ - { label: 'Reader', value: 'reader' }, + { label: 'Viewer', value: 'reader' }, { label: 'Commenter', value: 'commenter' }, { label: 'Editor', value: 'writer' }, ], @@ -47,11 +48,13 @@ export const setPublicAccess = createAction({ const res = await drive.permissions.create({ fileId: fileId, requestBody: permission, + supportsAllDrives: true, }); const file = await drive.files.get({ fileId: fileId, fields: 'name,mimeType,webContentLink,webViewLink', + supportsAllDrives: true, }); if (file.data.mimeType === 'application/vnd.google-apps.folder') { diff --git a/packages/pieces/community/google-drive/src/lib/action/upload-file.ts b/packages/pieces/community/google-drive/src/lib/action/upload-file.ts index 3d105c75b038..b7d92daf4e8a 100644 --- a/packages/pieces/community/google-drive/src/lib/action/upload-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/upload-file.ts @@ -10,19 +10,20 @@ export const googleDriveUploadFile = createAction({ auth: googleDriveAuth, name: 'upload_gdrive_file', classification: 'WRITE', - description: 'Upload a file in your Google Drive', + description: 'Upload a file to Google Drive.', audience: 'human', aiMetadata: { description: 'Uploads a binary file (from a URL or base64 input) into Google Drive, optionally inside a parent folder. Use to store an existing file or attachment in Drive; the MIME type is inferred from the file extension. Not idempotent: each call creates a new file.', idempotent: false }, - displayName: 'Upload file', + displayName: 'Upload File', props: { fileName: Property.ShortText({ - displayName: 'File name', - description: 'The name of the file', + displayName: 'File Name', + description: 'Name shown in Drive, including the extension.', required: true, + placeholder: 'photo.png', }), file: Property.File({ displayName: 'File', - description: 'The file URL or base64 to upload', + description: 'The file to upload, from an earlier step or a URL.', required: true, streaming: true, }), diff --git a/packages/pieces/community/google-drive/src/lib/common/index.ts b/packages/pieces/community/google-drive/src/lib/common/index.ts index aa86287eee6c..4720e402d97c 100644 --- a/packages/pieces/community/google-drive/src/lib/common/index.ts +++ b/packages/pieces/community/google-drive/src/lib/common/index.ts @@ -27,7 +27,7 @@ async function fetchFolderDropdownOptions({ return { disabled: true, options: [], - placeholder: 'Please authenticate first', + placeholder: 'Connect your Google account first', }; } const accessToken = await getAccessToken(auth); @@ -71,7 +71,7 @@ async function fetchFolderDropdownOptions({ return { disabled: false, placeholder: truncated - ? `Showing first ${folders.length} matches — type to narrow the list, or switch to Dynamic value to paste an ID.` + ? `Showing the first ${folders.length} matches. Type to narrow the list, or paste an ID with Dynamic value.` : undefined, options: folders.map((folder: { id: string; name: string }) => { return { @@ -82,32 +82,46 @@ async function fetchFolderDropdownOptions({ }; } +function parentFolderDropdown({ + displayName, + description, +}: { + displayName: string; + description: string; +}) { + return Property.Dropdown({ + displayName, + description, + required: false, + auth: googleDriveAuth, + refreshers: ['include_team_drives'], + refreshOnSearch: true, + options: async ({ auth, include_team_drives }, ctx) => + fetchFolderDropdownOptions({ + auth: auth as GoogleDriveAuthValue | undefined, + searchValue: ctx?.searchValue, + includeTeamDrives: include_team_drives as boolean | undefined, + }), + }); +} + export const common = { properties: { - parentFolder: Property.Dropdown({ + parentFolder: parentFolderDropdown({ displayName: 'Parent Folder', description: - "The Drive folder to target. Leave empty to use the root of My Drive. Type in the box to search your Drive by folder name. If the folder still isn't listed, switch this field to 'Dynamic value' (the toggle next to the field) and paste the folder ID — you can copy it from the folder's URL in Drive, after /folders/ (e.g. https://drive.google.com/drive/folders/).", - required: false, - auth: googleDriveAuth, - refreshers: ['include_team_drives'], - refreshOnSearch: true, - options: async ({ auth, include_team_drives }, ctx) => - fetchFolderDropdownOptions({ - auth: auth as GoogleDriveAuthValue | undefined, - searchValue: ctx?.searchValue, - includeTeamDrives: include_team_drives as boolean | undefined, - }), + 'Leave empty for the root of My Drive. Type to search by folder name.', }), include_team_drives: Property.Checkbox({ displayName: 'Include Team Drives', - description: - 'Determines if folders from Team Drives should be included in the results.', + description: 'Also include shared drives, not only My Drive.', defaultValue: false, required: false, }), }, + parentFolderDropdown, + escapeDriveQueryLiteral, fetchFolderDropdownOptions, async getFiles( diff --git a/packages/pieces/community/google-drive/src/lib/common/search-query.ts b/packages/pieces/community/google-drive/src/lib/common/search-query.ts new file mode 100644 index 000000000000..c2246bb2f6b3 --- /dev/null +++ b/packages/pieces/community/google-drive/src/lib/common/search-query.ts @@ -0,0 +1,33 @@ +import { common } from './index'; + +const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'; + +export function buildDriveSearchQuery({ + queryTerm, + operator, + query, + parentFolder, + type, +}: { + queryTerm: string; + operator: string; + query: string; + parentFolder: string | undefined; + type: string; +}): string { + const clauses = [ + `${queryTerm} ${operator} '${common.escapeDriveQueryLiteral(query)}'`, + ]; + if (parentFolder) { + clauses.push( + `'${common.escapeDriveQueryLiteral(parentFolder)}' in parents` + ); + } + if (type === 'file') { + clauses.push(`mimeType!='${FOLDER_MIME_TYPE}'`); + } + if (type === 'folder') { + clauses.push(`mimeType='${FOLDER_MIME_TYPE}'`); + } + return clauses.join(' and '); +} diff --git a/packages/pieces/community/google-drive/src/lib/triggers/new-file.ts b/packages/pieces/community/google-drive/src/lib/triggers/new-file.ts index 0ed25f13bcd2..52b2843423de 100644 --- a/packages/pieces/community/google-drive/src/lib/triggers/new-file.ts +++ b/packages/pieces/community/google-drive/src/lib/triggers/new-file.ts @@ -41,16 +41,22 @@ export const newFile = createTrigger({ name: 'new_file', classification: 'READ', displayName: 'New File', - description: 'Trigger when a new file is uploaded.', + description: + 'Triggers when a new file is added to Drive or the chosen folder.', aiMetadata: { description: 'Fires when a new file appears in Google Drive, optionally scoped to a specific parent folder. Each event represents one newly created file and its metadata, with optional inclusion of the file content.', }, props: { - parentFolder: common.properties.parentFolder, + parentFolder: common.parentFolderDropdown({ + displayName: 'Folder', + description: + 'Leave empty to watch all of My Drive. Type to search by folder name.', + }), include_team_drives: common.properties.include_team_drives, include_file_content: Property.Checkbox({ displayName: 'Include File Content', - description: 'Include the file content in the output. This will increase the time taken to fetch the files and might cause issues with large files.', + description: + 'Also download each file. Slower, and very large files may fail.', required: false, defaultValue: false }), @@ -97,7 +103,10 @@ export const newFile = createTrigger({ mimeType: 'image/png', id: '1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5', name: 'google-drive.png', - link: 'https://cdn.activepieces.com/pieces/google-drive.png' + webViewLink: + 'https://drive.google.com/file/d/1dpv4-sKJfKRwI9qx1vWqQhEGEn3EpbI5/view?usp=drivesdk', + createdTime: '2026-08-20T09:12:44.000Z', + modifiedTime: '2026-08-20T09:12:44.000Z', }, }); diff --git a/packages/pieces/community/google-drive/src/lib/triggers/new-folder.ts b/packages/pieces/community/google-drive/src/lib/triggers/new-folder.ts index 7a7b9b2131ce..765ee39d7731 100644 --- a/packages/pieces/community/google-drive/src/lib/triggers/new-folder.ts +++ b/packages/pieces/community/google-drive/src/lib/triggers/new-folder.ts @@ -39,12 +39,17 @@ export const newFolder = createTrigger({ name: 'new_folder', classification: 'READ', displayName: 'New Folder', - description: 'Trigger when a new folder is created or uploaded.', + description: + 'Triggers when a new folder is created in Drive or the chosen folder.', aiMetadata: { description: 'Fires when a new folder is created in Google Drive, optionally scoped to a specific parent folder. Each event represents one newly created folder and its metadata.', }, props: { - parentFolder: common.properties.parentFolder, + parentFolder: common.parentFolderDropdown({ + displayName: 'Folder', + description: + 'Leave empty to watch all of My Drive. Type to search by folder name.', + }), include_team_drives: common.properties.include_team_drives, }, outputSchema: newFolderTriggerOutputSchema, @@ -74,6 +79,7 @@ export const newFolder = createTrigger({ kind: 'drive#file', mimeType: 'application/vnd.google-apps.folder', id: '1aMEtTqIYn5651wdK7WLxaK_SDim4mvXW', - name: 'New Folder WOOOO', + name: 'Invoices 2026', + createdTime: '2026-08-20T09:12:44.000Z', }, }); diff --git a/packages/pieces/community/google-drive/src/lib/triggers/new-or-updated-file.ts b/packages/pieces/community/google-drive/src/lib/triggers/new-or-updated-file.ts index 448f9467e7d9..6bdca96dc1eb 100644 --- a/packages/pieces/community/google-drive/src/lib/triggers/new-or-updated-file.ts +++ b/packages/pieces/community/google-drive/src/lib/triggers/new-or-updated-file.ts @@ -1,6 +1,7 @@ import { AppConnectionValueForAuthProperty, FilesService, + MarkdownVariant, Property, TriggerStrategy, createTrigger, @@ -56,18 +57,27 @@ export const newOrUpdatedFile = createTrigger({ classification: 'READ', displayName: 'New or Updated File', description: - 'Trigger when a file is created or updated, checked on a schedule. Each event carries a Change Type of created or updated, and several edits between two checks arrive as a single event. Renaming a file counts as an update. Trashing a file is not an event, and neither is restoring one from the bin nor moving an existing file into the watched folder. Selecting a parent folder watches its direct children only, not sub-folders.', + 'Triggers when a file is created or edited. A rename counts; trashing or moving does not.', aiMetadata: { description: 'Fires when a file is created or modified in Google Drive, based on its creation and last-modified times (polling), optionally scoped to a parent folder and to specific file types. Each event represents one file and its metadata, carries a changeType of created or updated, and can include the file content, falling back to a contentError field when a download fails. Choose this over New File when edits to files that already exist should also trigger the flow.', }, props: { - parentFolder: common.properties.parentFolder, + behaviourNote: Property.MarkDown({ + value: + 'Each event carries a Change Type of created or updated. Several edits between two checks arrive as one event. A chosen folder is watched one level deep, not its subfolders.', + variant: MarkdownVariant.INFO, + }), + parentFolder: common.parentFolderDropdown({ + displayName: 'Folder', + description: + 'Leave empty to watch all of My Drive. Type to search by folder name.', + }), include_team_drives: common.properties.include_team_drives, file_types: Property.StaticMultiSelectDropdown({ displayName: 'File Types', description: - "Only fire for files of these types. Leave empty to watch every type. If the type you need is not listed, switch this field to 'Dynamic value' (the toggle next to the field) and provide a list of MIME types.", + 'Empty watches every type. Use Dynamic value to add other MIME types.', required: false, options: { options: [ @@ -99,7 +109,7 @@ export const newOrUpdatedFile = createTrigger({ include_file_content: Property.Checkbox({ displayName: 'Include File Content', description: - 'Include the file content in the output. This will increase the time taken to fetch the files and might cause issues with large files. If a download fails the event still arrives, carrying a File Content Error instead of the content.', + 'Also download each file. A failed download adds an error instead.', required: false, defaultValue: false, }), diff --git a/packages/pieces/community/google-drive/test/search-query.test.ts b/packages/pieces/community/google-drive/test/search-query.test.ts new file mode 100644 index 000000000000..65f02c96a6ce --- /dev/null +++ b/packages/pieces/community/google-drive/test/search-query.test.ts @@ -0,0 +1,66 @@ +/// + +import { buildDriveSearchQuery } from '../src/lib/common/search-query'; + +const base = { + queryTerm: 'name', + operator: 'contains', + query: 'Quarterly report', + parentFolder: undefined, + type: 'all', +}; + +describe('buildDriveSearchQuery', () => { + it('builds a plain name search', () => { + expect(buildDriveSearchQuery(base)).toBe("name contains 'Quarterly report'"); + }); + + it('escapes apostrophes in the search text', () => { + expect(buildDriveSearchQuery({ ...base, query: "Tom's notes" })).toBe( + "name contains 'Tom\\'s notes'" + ); + }); + + it('escapes backslashes before apostrophes in the search text', () => { + expect(buildDriveSearchQuery({ ...base, query: "a\\'b" })).toBe( + "name contains 'a\\\\\\'b'" + ); + }); + + it('keeps an injected clause inside the literal', () => { + const query = buildDriveSearchQuery({ + ...base, + query: "x' or trashed=true and name contains 'y", + }); + expect(query).toBe( + "name contains 'x\\' or trashed=true and name contains \\'y'" + ); + }); + + it('escapes the parent folder id', () => { + expect( + buildDriveSearchQuery({ ...base, parentFolder: "id' or '1'='1" }) + ).toBe("name contains 'Quarterly report' and 'id\\' or \\'1\\'=\\'1' in parents"); + }); + + it('omits the parents clause when no folder is chosen', () => { + expect(buildDriveSearchQuery({ ...base, parentFolder: '' })).not.toContain( + 'in parents' + ); + }); + + it('narrows to files or folders', () => { + expect(buildDriveSearchQuery({ ...base, type: 'file' })).toBe( + "name contains 'Quarterly report' and mimeType!='application/vnd.google-apps.folder'" + ); + expect(buildDriveSearchQuery({ ...base, type: 'folder' })).toBe( + "name contains 'Quarterly report' and mimeType='application/vnd.google-apps.folder'" + ); + }); + + it('uses the chosen term and operator', () => { + expect( + buildDriveSearchQuery({ ...base, queryTerm: 'mimeType', operator: '=', query: 'text/csv' }) + ).toBe("mimeType = 'text/csv'"); + }); +}); diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index 6d2d4340e78b..72f3d54c81b9 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -123,6 +123,17 @@ export const authenticationController: FastifyPluginAsyncZod = async ( method: attributionUtils.signUpMethodFromProvider({ provider }), attribution: request.body.attribution, }), request.log) + rejectedPromiseHandler(telemetry(request.log).trackUser({ + userId: response.id, + platformId: response.platformId, + event: { + name: TelemetryEventName.ONBOARDING_COMPLETED, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, + }), request.log) } return response diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index 18b536ea8031..bdf0fdb6f030 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -1,6 +1,6 @@ import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/core-utils' import { cryptoUtils } from '@activepieces/server-utils' -import { ApFlagId, OtpType, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' +import { ApFlagId, OtpType, TelemetryEvent, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { flagService } from '../flags/flag.service' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -85,13 +85,14 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ const preferredPlatformId = isNil(platformId) ? await authenticationService(log).selectCloudSignInPlatformId({ identityId: verifiedIdentity.id }) : platformId - rejectedPromiseHandler(telemetry(log).trackIdentity({ + rejectedPromiseHandler(trackForIdentity({ identityId: verifiedIdentity.id, platformId: preferredPlatformId, event: { name: TelemetryEventName.EMAIL_CODE_VERIFIED, payload: { needsNameStep: isNil(preferredPlatformId) }, }, + log, }), log) if (!isNil(platformId)) { @@ -106,6 +107,7 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ identity: verifiedIdentity, platformId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId: verifiedIdentity.id, userId: user.id, platformId }), log) await userInvitationsService(log).provisionUserInvitation({ email }) const response = await authenticationUtils(log).getProjectAndToken({ userId: user.id, @@ -121,6 +123,7 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ identity: verifiedIdentity, platformId: preferredPlatformId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId: verifiedIdentity.id, userId: user.id, platformId: preferredPlatformId }), log) const response = await authenticationUtils(log).getProjectAndToken({ userId: user.id, platformId: preferredPlatformId, @@ -153,16 +156,26 @@ async function trackEmailCodeRequested({ identityId, platformId, isNewIdentity, const preferredPlatformId = isNil(platformId) ? await authenticationService(log).selectCloudSignInPlatformId({ identityId }) : platformId - await telemetry(log).trackIdentity({ + await trackForIdentity({ identityId, platformId: preferredPlatformId, event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity }, }, + log, }) } +async function trackForIdentity({ identityId, platformId, event, log }: TrackForIdentityParams): Promise { + const user = isNil(platformId) ? null : await userService(log).getOneByIdentityAndPlatform({ identityId, platformId }) + if (!isNil(user) && !isNil(platformId)) { + await telemetry(log).trackUser({ userId: user.id, platformId, event }) + return + } + await telemetry(log).trackIdentity({ identityId, platformId, event }) +} + async function assertPlatformAuthIsOpenTo({ email, platformId, log }: PlatformGateParams): Promise { await authenticationUtils(log).assertEmailAuthIsEnabled({ platformId, @@ -190,6 +203,13 @@ type TrackEmailCodeRequestedParams = { log: FastifyBaseLogger } +type TrackForIdentityParams = { + identityId: string + platformId: string | null + event: TelemetryEvent + log: FastifyBaseLogger +} + type RequestCodeParams = { email: string platformId: string | null diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts index 657a5ae89036..aaba391f9689 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -1,7 +1,7 @@ import { ExecuteAgentRunJobData } from '@activepieces/core-execution' -import { ActivepiecesAiBilling, ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' +import { ActivepiecesAiBilling, ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, spreadIfDefined, tryCatch, tryCatchSync, unique } from '@activepieces/core-utils' import { aiUtils } from '@activepieces/server-utils' -import { AgentConfig, AgentConversation, AgentConversationStatus, AI_PROVIDER_ENTITY_TYPES, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' +import { AgentConfig, AgentConversation, AgentConversationStatus, AI_PROVIDER_ENTITY_TYPES, AIProviderModelType, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' import { SharedV3ProviderOptions } from '@ai-sdk/provider' import { EmbeddingModel, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' @@ -145,23 +145,40 @@ async function assertRunProviderConfigured({ platformId, provider, providerConfi } } -async function resolveTierModel({ platformId, tierId, provider, providerConfigId, scope, log }: { platformId: string, tierId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { - const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) }) - const modelId = agentModelResolution.resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel: tierId, config: providerConfig.config, modelScope: providerConfig.modelScope, modelIds: providerConfig.modelIds }) - return { - model: aiUtils.createModel({ - credentials: providerConfig, - modelId, - platformId, - providerConfigId: providerConfig.configId, - }), - modelId, - provider: providerConfig.provider, +async function resolveModelId({ platformId, providerConfig, selectedModel, scope, fallbackModelId, log }: { platformId: string, providerConfig: GetProviderConfigResponse, selectedModel: string | null, scope: ProviderScope, fallbackModelId?: string, log: FastifyBaseLogger }): Promise { + const { provider, config, modelScope, modelIds, configId } = providerConfig + const { data, error } = tryCatchSync(() => agentModelResolution.resolveModelIdForProvider({ provider, selectedModel, config, modelScope, modelIds })) + if (!isNil(data)) { + return data + } + const keyServesNoKnownModel = error instanceof ActivepiecesError && error.error.code === ErrorCode.ENTITY_NOT_FOUND + if (!keyServesNoKnownModel) { + throw error + } + const fallbackStillAllowed = !isNil(fallbackModelId) && (modelScope !== 'selected' || modelIds.includes(fallbackModelId)) + if (fallbackStillAllowed) { + return fallbackModelId } + const offered = await aiProviderService(log).listModels({ platformId, provider, scope, configId }) + const textModels = offered.filter((model) => model.type === AIProviderModelType.TEXT) + if (textModels.length === 0) { + throw error + } + const tier = agentModelResolution.resolveTier({ tierId: selectedModel }) + const picked = textModels.find((model) => model.id === selectedModel) + ?? textModels.find((model) => model.id.includes(tier.nativeModelId)) + ?? textModels[0] + return picked.id +} + +async function resolveFastModelId({ platformId, providerConfig, scope, fallbackModelId, log }: { platformId: string, providerConfig: GetProviderConfigResponse, scope: ProviderScope, fallbackModelId?: string, log: FastifyBaseLogger }): Promise { + return resolveModelId({ platformId, providerConfig, selectedModel: FAST_TIER_ID, scope, log, ...spreadIfDefined('fallbackModelId', fallbackModelId) }) } -async function resolveFastModel({ platformId, provider, providerConfigId, scope, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { - return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) })).model +async function resolveFastModel({ platformId, provider, providerConfigId, scope, fallbackModelId, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, fallbackModelId?: string, log: FastifyBaseLogger }): Promise { + const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) }) + const modelId = await resolveFastModelId({ platformId, providerConfig, scope, log, ...spreadIfDefined('fallbackModelId', fallbackModelId) }) + return aiUtils.createModel({ credentials: providerConfig, modelId, platformId, providerConfigId: providerConfig.configId }) } @@ -318,7 +335,8 @@ export const agentHelpers = { assertRunProviderConfigured, ...agentModelResolution, resolveFastModel, - resolveTierModel, + resolveFastModelId, + resolveModelId, resolveRunProvider, resolveEmbeddingModel, resolveChatProviderName, diff --git a/packages/server/api/src/app/ee/agent/agent-model-resolution.ts b/packages/server/api/src/app/ee/agent/agent-model-resolution.ts index 65f17175d081..72278a5a624f 100644 --- a/packages/server/api/src/app/ee/agent/agent-model-resolution.ts +++ b/packages/server/api/src/app/ee/agent/agent-model-resolution.ts @@ -87,10 +87,6 @@ function resolveModelIdForAnalytics({ provider, selectedModel }: { provider: AIP return aiProviderUtils.isCuratedChatModelId({ modelId: selectedModel }) ? selectedModel : null } -function resolveFastModelId({ provider, config, modelScope, modelIds }: { provider: AIProviderName, config?: AiProviderCredentials['config'], modelScope?: AiProviderModelScope, modelIds?: string[] }): string { - return resolveModelIdForProvider({ provider, selectedModel: FAST_TIER_ID, config, modelScope, modelIds }) -} - export const agentModelResolution = { findTier, resolveTier, @@ -98,7 +94,6 @@ export const agentModelResolution = { resolveModelIdForProvider, defaultModelIdForProvider, resolveModelIdForAnalytics, - resolveFastModelId, } export const FAST_TIER_ID = 'fast' diff --git a/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts b/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts index 8a9aeef2297a..b27167dd4a54 100644 --- a/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts +++ b/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts @@ -250,13 +250,13 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ findRow({ platformId, userId: null }), tryCatch(() => aiToolConfigService(log).getEnabledTools({ platformId })), ]) - const providerName = provider.provider const webSearch = enabledTools.data?.webSearch ?? null + const modelId = await agentHelpers.resolveModelId({ platformId, providerConfig: provider, selectedModel: null, scope: PERSONALIZATION_PROVIDER_SCOPE, log }) return { claimed: true, credentials: provider, - modelId: agentHelpers.resolveModelIdForProvider({ provider: providerName, selectedModel: null, config: provider.config, modelScope: provider.modelScope, modelIds: provider.modelIds }), - fastModelId: agentHelpers.resolveFastModelId({ provider: providerName, config: provider.config, modelScope: provider.modelScope, modelIds: provider.modelIds }), + modelId, + fastModelId: await agentHelpers.resolveFastModelId({ platformId, providerConfig: provider, scope: PERSONALIZATION_PROVIDER_SCOPE, log }), user: { firstName: user.firstName, lastName: user.lastName, email: user.email }, platformName: platform.name, website: companyRow?.domain ?? null, diff --git a/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts b/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts index 73bab9ce314e..44d66fb08f5e 100644 --- a/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts +++ b/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts @@ -83,7 +83,8 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({ // it then adopted. A flow step reads its own conversation's project rather than the // selection above, which narrows to what the owner can still see in chat. const runProjectId = isFlowStep ? conversation.projectId ?? null : selectedProjectId - const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) + const runScope = agentHelpers.runScopeOrThrow({ projectId: runProjectId }) + const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: runScope, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) const attachmentRefs = files && files.length > 0 && !isNil(selectedProjectId) ? await persistAgentAttachments({ files, projectId: selectedProjectId, platformId, log }) @@ -126,7 +127,8 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({ const tier = agentHelpers.resolveTier({ tierId: namesItsOwnModel ? null : selectedModel }) const resolvedModelId = namesItsOwnModel && !isNil(modelName) ? agentHelpers.resolveNamedModelId({ provider: providerConfig.provider, modelName, modelScope: providerConfig.modelScope, modelIds: providerConfig.modelIds }) - : agentHelpers.resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel, config: providerConfig.config, modelScope: providerConfig.modelScope, modelIds: providerConfig.modelIds }) + : await agentHelpers.resolveModelId({ platformId, providerConfig, selectedModel, scope: runScope, log }) + const fastModelId = await agentHelpers.resolveFastModelId({ platformId, providerConfig, scope: runScope, fallbackModelId: resolvedModelId, log }) // Inject an inventory of the project's existing connections into context so the agent // never has to *guess* an app name to find out what's connected. Without this, discovery @@ -240,7 +242,7 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({ credentials: providerConfig, providerConfigId: providerConfig.configId, modelId: resolvedModelId, - fastModelId: agentHelpers.resolveFastModelId({ provider: providerConfig.provider, config: providerConfig.config, modelScope: providerConfig.modelScope, modelIds: providerConfig.modelIds }), + fastModelId, systemPrompt: systemPromptText, messages: messagesForLlm, allMessages, diff --git a/packages/server/api/src/app/ee/agent/rpc/rpc-shared.ts b/packages/server/api/src/app/ee/agent/rpc/rpc-shared.ts index 83ef9284469e..5111e8674357 100644 --- a/packages/server/api/src/app/ee/agent/rpc/rpc-shared.ts +++ b/packages/server/api/src/app/ee/agent/rpc/rpc-shared.ts @@ -2,6 +2,7 @@ import { ActivepiecesError, connectionTemplate, ErrorCode, isNil, Permission, sp import { ActionClassification, isReadOnlyClassification } from '@activepieces/pieces-framework' import { AgentActionKind, AgentActionOutcome, AgentActionRef, AgentConversation, AgentConversationStatus, AgentPieceToolMetadata, AgentRunSource, agentToolClassification, ApplicationEventName } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { AgentConversationWithRelations } from '.././agent-conversation-entity' import { agentHelpers } from '.././agent-helpers' import { agentService } from '.././agent-service' import { agentToolPinning } from '.././agent-tool-pinning' @@ -48,6 +49,17 @@ export async function connectionForConfiguredTool({ piece, projectId, platformId return { externalId: pinned, ...spreadIfDefined('label', connection?.displayName) } } +function pinnedModelOf({ conversation }: { conversation: AgentConversationWithRelations }): string | undefined { + const runConfig = conversation.source === AgentRunSource.FLOW_STEP + ? conversation.agent?.published + : conversation.agent?.draft + const pinned = runConfig?.modelName ?? conversation.modelName ?? null + if (isNil(pinned) || !isNil(agentHelpers.findTier({ tierId: pinned }))) { + return undefined + } + return pinned +} + export async function configuredToolConversationOrThrow({ conversationId }: { conversationId: string }): Promise { const conversation = await agentHelpers.conversationRepo().findOne({ where: { id: conversationId }, relations: { agent: true } }) if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { @@ -58,6 +70,7 @@ export async function configuredToolConversationOrThrow({ conversationId }: { co platformId: conversation.platformId, userId: conversation.userId, source: conversation.source, + ...spreadIfDefined('runModelId', pinnedModelOf({ conversation })), ...spreadIfDefined('agent', isNil(conversation.agentId) ? undefined : { id: conversation.agentId, ...spreadIfDefined('displayName', conversation.agent?.displayName), @@ -192,6 +205,7 @@ export type ConfiguredToolRun = { platformId: string userId: string source: AgentRunSource + runModelId?: string agent?: { id: string, displayName?: string } } diff --git a/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts b/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts index 520b368d50f4..c6b300e5e793 100644 --- a/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts +++ b/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts @@ -17,8 +17,8 @@ import { byteLengthOf, CONFIGURED_TOOL_SOURCES, configuredToolConversationOrThro export const toolExecutionRpc = (log: FastifyBaseLogger) => ({ async executePieceTool(input: ExecutePieceToolRequest): Promise { const configuredRun = await configuredToolConversationOrThrow({ conversationId: input.conversationId }) - const { projectId, platformId } = configuredRun - const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) + const { projectId, platformId, runModelId } = configuredRun + const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId), ...spreadIfDefined('fallbackModelId', runModelId) }) const piece = { pieceName: input.piece.pieceName, actionName: input.piece.actionName, ...spreadIfDefined('pieceVersion', input.piece.pieceVersion) } const connection = await connectionForConfiguredTool({ piece: input.piece, projectId, platformId, log }) const { data: resolved, error: resolveError } = await tryCatch(() => pieceToolRunner.resolveInput({ diff --git a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts index 40471087639b..85670ea5c611 100644 --- a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts +++ b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts @@ -1,5 +1,5 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, ClaimTokenRequest, SignUpMethod, ThirdPartyAuthnProviderEnum } from '@activepieces/shared' +import { ApplicationEventName, ClaimTokenRequest, SignUpMethod, TelemetryEventName, ThirdPartyAuthnProviderEnum } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' @@ -51,6 +51,19 @@ const federatedAuthnController: FastifyPluginAsyncZod = async (app) => { attribution: req.body.attribution, }), req.log) } + else if (!isNil(response.platformId)) { + rejectedPromiseHandler(telemetry(req.log).trackUser({ + userId: response.id, + platformId: response.platformId, + event: { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, + }), req.log) + } return response }) } diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts index 97a6321cfae6..8cc16f2f2d34 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts @@ -1,12 +1,14 @@ import { assertNotNullOrUndefined } from '@activepieces/core-utils' -import { ApplicationEventName, PrincipalType } from '@activepieces/shared' +import { ApplicationEventName, PrincipalType, TelemetryEventName } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' import { applicationEvents } from '../../../helper/application-events' import { networkUtils } from '../../../helper/network-utils' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' import { system } from '../../../helper/system/system' import { AppSystemProp } from '../../../helper/system/system-props' +import { telemetry } from '../../../helper/telemetry.utils' import { platformUtils } from '../../../platform/platform.utils' import { platformMustHaveFeatureEnabled } from '../ee-authorization' import { authnSsoSamlService } from './authn-sso-saml-service' @@ -43,6 +45,17 @@ export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => { source: 'sso', }, }) + rejectedPromiseHandler(telemetry(req.log).trackUser({ + userId: response.id, + platformId, + event: { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId, + }, + }, + }), req.log) return res.redirect(url.toString()) }) diff --git a/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts b/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts index e67ac8f9f6b4..c368b5064572 100644 --- a/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts +++ b/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, apId, ApId, ApplicationEventName, Cursor, ErrorCode, Flow, FlowApprovalRequest, FlowApprovalRequestState, FlowOperationType, FlowStatus, FlowVersionState, isNil, PlatformId, PopulatedFlowApprovalRequest, Principal, PrincipalType, ProjectId, SeekPage, UserId } from '@activepieces/shared' +import { ActivepiecesError, apId, ApId, ApplicationEventName, Cursor, ErrorCode, Flow, FlowApprovalRequest, FlowApprovalRequestState, FlowOperationType, FlowStatus, FlowVersionState, isNil, PlatformId, PopulatedFlowApprovalRequest, Principal, PrincipalType, ProjectId, SeekPage, TelemetryEventName, UserId } from '@activepieces/shared' import { FastifyBaseLogger, FastifyRequest } from 'fastify' import { repoFactory } from '../../../core/db/repo-factory' import { transaction } from '../../../core/db/transaction' @@ -9,6 +9,8 @@ import { applicationEvents } from '../../../helper/application-events' import { buildPaginator } from '../../../helper/pagination/build-paginator' import { paginationHelper } from '../../../helper/pagination/pagination-utils' import { Order } from '../../../helper/pagination/paginator' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' +import { telemetry } from '../../../helper/telemetry.utils' import { triggerSourceService } from '../../../trigger/trigger-source/trigger-source-service' import { FlowApprovalRequestEntity } from './flow-approval-request.entity' @@ -124,6 +126,13 @@ export const flowApprovalRequestService = (log: FastifyBaseLogger) => ({ projectId: approval.projectId, newStatus: approval.requestedStatus, }) + rejectedPromiseHandler(telemetry(log).trackProject({ + projectId: approval.projectId, + event: { + name: TelemetryEventName.FLOW_PUBLISHED, + payload: { flowId: flow.id }, + }, + }), log) applicationEvents(log).sendUserEvent(request, { action: ApplicationEventName.FLOW_APPROVAL_GRANTED, data: { diff --git a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts index 6dc59a97d440..9e5005a7ff3a 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts @@ -23,7 +23,8 @@ import { AppSystemProp } from '../../../../helper/system/system-props' import { AppSumoAction, CancellationFeedback, CreditUsage, CreditUsageSource } from '../../../../platform/billing-provider' import { platformService } from '../../../../platform/platform.service' import { userService } from '../../../../user/user-service' -import { platformPlanService } from '../platform-plan.service' +import { platformPlanTelemetry } from '../platform-plan-telemetry' +import { platformPlanRepo, platformPlanService } from '../platform-plan.service' const AUTUMN_CONSOLE_URL = system.getOrThrow(AppSystemProp.AUTUMN_CONSOLE_URL).replace(/\/+$/, '') const edition = system.getEdition() @@ -162,10 +163,18 @@ export const autumnUtils = { } const customer = await client.getCustomer({ expand: ['subscriptions.plan', 'purchases.plan'] }) const entitlements = toAutumnEntitlements(customer) + const previousPlan = await platformPlanRepo().findOneBy({ platformId }) await platformPlanService(log).update({ platformId, ...autumnUtils.mapAutumnFeaturesToPlatformPlan(entitlements) }) await autumnUtils.writeCustomerStateCaches({ platformId, customer, grantedFeatureIds: entitlements.grantedFeatureIds }) await autumnUtils.invalidateBillingOverview(platformId) await autumnUtils.provisionLicenseKeyIfPaid(log, platformId, entitlements.planId) + const currentSubscription = autumnUtils.selectCurrentBaseSubscription(autumnUtils.toBaseSubscriptions(customer)) + rejectedPromiseHandler(platformPlanTelemetry(log).onEntitlementsRefreshed({ + platformId, + previousPlan: previousPlan?.plan ?? null, + plan: entitlements.planId, + trialEndsAt: isNil(currentSubscription?.trialEndsAt) ? null : new Date(currentSubscription.trialEndsAt).toISOString(), + }), log) }, async provisionLicenseKeyIfPaid(log: FastifyBaseLogger, platformId: string, planId: string | null): Promise { if (isNil(planId) || planId === PlanName.FREE || planId === PlanName.APPSUMO || planId === PlanName.FREE_LEGACY) { diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts new file mode 100644 index 000000000000..65de4577258e --- /dev/null +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts @@ -0,0 +1,122 @@ +import { isNil } from '@activepieces/core-utils' +import { PlanName, TelemetryEventName } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { distributedStore } from '../../../database/redis-connections' +import { telemetry } from '../../../helper/telemetry.utils' +import { platformService } from '../../../platform/platform.service' +import { platformPlanRepo } from './platform-plan.service' + +export const platformPlanTelemetry = (log: FastifyBaseLogger) => ({ + async onCheckoutStarted({ platformId, planId }: CheckoutStartedParams): Promise { + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.CHECKOUT_STARTED, + payload: { platformId, plan: planId }, + }, + }) + }, + + async onCancelled({ platformId }: PlanActionParams): Promise { + const plan = await currentPlanName({ platformId }) + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_CANCELLED, + payload: { platformId, plan }, + }, + }) + }, + + async onReactivated({ platformId }: PlanActionParams): Promise { + const plan = await currentPlanName({ platformId }) + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_REACTIVATED, + payload: { platformId, plan }, + }, + }) + }, + + async onEntitlementsRefreshed({ platformId, previousPlan, plan, trialEndsAt }: EntitlementsRefreshedParams): Promise { + if (plan === previousPlan) { + return + } + const platform = await platformService(log).getOneOrThrow(platformId) + await telemetry(log).identifyPlatformGroup({ + platformId, + properties: { name: platform.name, plan, createdAt: platform.created }, + }) + if (isNil(plan) || FREE_PLANS.has(plan)) { + return + } + await distributedStore.runOnceWithin( + planChangeKey({ platformId, previousPlan, plan }), + PLAN_CHANGE_ONCE_SECONDS, + () => trackPlanChange({ platformId, previousPlan, plan, trialEndsAt, log }), + ) + }, +}) + +async function trackPlanChange({ platformId, previousPlan, plan, trialEndsAt, log }: TrackPlanChangeParams): Promise { + const trialing = !isNil(trialEndsAt) && new Date(trialEndsAt).getTime() > Date.now() + if (trialing) { + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.TRIAL_STARTED, + payload: { platformId, plan, trialEndsAt }, + }, + }) + return + } + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_CHANGED, + payload: { platformId, plan, previousPlan: previousPlan ?? undefined }, + }, + }) +} + +function planChangeKey({ platformId, previousPlan, plan }: PlanChangeKeyParams): string { + return `telemetry:plan-change:${platformId}:${previousPlan ?? 'none'}:${plan}` +} + +async function currentPlanName({ platformId }: PlanActionParams): Promise { + const platformPlan = await platformPlanRepo().findOneBy({ platformId }) + return platformPlan?.plan ?? UNKNOWN_PLAN +} + +const UNKNOWN_PLAN = 'unknown' + +const PLAN_CHANGE_ONCE_SECONDS = 24 * 60 * 60 + +const FREE_PLANS: ReadonlySet = new Set([PlanName.FREE, PlanName.FREE_LEGACY, PlanName.APPSUMO]) + +type PlanActionParams = { + platformId: string +} + +type CheckoutStartedParams = PlanActionParams & { + planId: string +} + +type EntitlementsRefreshedParams = { + platformId: string + previousPlan: string | null + plan: string | null + trialEndsAt: string | null +} + +type PlanChangeKeyParams = { + platformId: string + previousPlan: string | null + plan: string +} + +type TrackPlanChangeParams = PlanChangeKeyParams & { + trialEndsAt: string | null + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts index f3feaabf649e..689dbc8b67a0 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts @@ -7,9 +7,11 @@ import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' import { getEntitlementsForceRefreshKey } from '../../../database/redis/keys' import { distributedStore } from '../../../database/redis-connections' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' import { billingProvider } from '../../../platform/billing-provider' import { platformService } from '../../../platform/platform.service' import { userService } from '../../../user/user-service' +import { platformPlanTelemetry } from './platform-plan-telemetry' import { platformPlanService } from './platform-plan.service' const FORCE_REFRESH_DEDUP_SECONDS = 60 @@ -54,6 +56,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { planId: request.body.planId, successUrl: request.body.successUrl, }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onCheckoutStarted({ platformId, planId: request.body.planId }), request.log) await refreshWhenAppliedImmediately({ log: request.log, platformId, checkoutUrl: result.checkoutUrl }) return result }) @@ -69,6 +72,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { canceledByEmail: await resolveActorEmail(request.log, request.principal.id), }, }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onCancelled({ platformId }), request.log) await provider.refreshEntitlements(platformId) }) @@ -76,6 +80,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { const platformId = request.principal.platform.id const provider = billingProvider.get(request.log) await provider.reactivateSubscription({ platformId }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onReactivated({ platformId }), request.log) await provider.refreshEntitlements(platformId) }) diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts index e46562642764..bc341fe6a12c 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts @@ -55,9 +55,10 @@ export const platformPlanService = (log: FastifyBaseLogger) => ({ return platformPlan }, - async onPlatformCreated(platformId: string): Promise { - await createInitialBilling(platformId) + async onPlatformCreated(platformId: string): Promise { + const platformPlan = await createInitialBilling(platformId) await enrollBillingProviderOnCreate(platformId, log) + return platformPlan }, async update(params: UpdatePlatformBillingParams): Promise { diff --git a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts index 1b4a84282347..4b53cbd0bdb0 100644 --- a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts +++ b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts @@ -243,9 +243,12 @@ async function resolveFlowVersionUpgrades({ flowVersion, log }: ResolveFlowVersi } async function sendUpgradeAuditEvent({ platformId, projectId, flowId, flowVersionId, decisions, log }: SendUpgradeAuditEventParams): Promise { - projectId = projectId ?? await flowRepo().findOneByOrFail({ id: flowId }).then((flow) => flow.projectId) - platformId = platformId ?? await projectService(log).getPlatformId(projectId!) - applicationEvents(log).sendUserEvent({ platformId, projectId }, { + const resolvedProjectId = projectId ?? (await flowRepo().findOneBy({ id: flowId }))?.projectId + if (isNil(resolvedProjectId)) { + return + } + const resolvedPlatformId = platformId ?? await projectService(log).getPlatformId(resolvedProjectId) + applicationEvents(log).sendUserEvent({ platformId: resolvedPlatformId, projectId: resolvedProjectId }, { action: ApplicationEventName.FLOW_PIECES_UPGRADED, data: { flowId, diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 8a5af5b4700a..9a4dc3416856 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -524,6 +524,13 @@ export const flowService = (log: FastifyBaseLogger) => ({ }) const { websocketService } = await import('../../core/websockets.service') websocketService.notifyWorkers().flowPublished({ flowId: publishedFlow.id, flowVersionId: publishedFlow.version.id, projectId: publishedFlow.projectId }) + rejectedPromiseHandler(telemetry(log).trackProject({ + projectId, + event: { + name: TelemetryEventName.FLOW_PUBLISHED, + payload: { flowId: id }, + }, + }), log) return publishedFlow }, diff --git a/packages/server/api/src/app/helper/telemetry.utils.ts b/packages/server/api/src/app/helper/telemetry.utils.ts index b491aa3a6e7d..3dfd1bc999f4 100644 --- a/packages/server/api/src/app/helper/telemetry.utils.ts +++ b/packages/server/api/src/app/helper/telemetry.utils.ts @@ -1,6 +1,6 @@ import { AIProviderName, isNil, ProjectId, spreadIfDefined, UserId } from '@activepieces/core-utils' import { apVersionUtil } from '@activepieces/server-utils' -import { ApEdition, AppInstance, AttributionParams, attributionUtils, DeploymentConfig, FlowRunStatus, GetDiagnosticsResponse, GetSystemHealthChecksResponse, isCloudOnlyTelemetryEvent, MachineInformation, pickTelemetryPii, RunEnvironment, SignUpMethod, TelemetryEvent, User, UserIdentity } from '@activepieces/shared' +import { ApEdition, ApEnvironment, AppInstance, AttributionParams, attributionUtils, DeploymentConfig, DeploymentKind, FlowRunStatus, GetDiagnosticsResponse, GetSystemHealthChecksResponse, isCloudOnlyTelemetryEvent, MachineInformation, pickTelemetryPii, RunEnvironment, SignUpMethod, TelemetryEvent, User, UserIdentity } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { PostHog } from 'posthog-node' import { platformConfigurationService } from '../platform/platform-configuration.service' @@ -57,6 +57,30 @@ export const telemetry = (log: FastifyBaseLogger) => ({ }, }) }, + async aliasIdentity({ identityId, userId, platformId }: AliasIdentityParams): Promise { + if (system.getEdition() !== ApEdition.CLOUD) { + return + } + if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { + return + } + getPostHog().alias({ distinctId: userId, alias: identityId }) + }, + async identifyPlatformGroup({ platformId, properties }: IdentifyPlatformGroupParams): Promise { + if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { + return + } + getPostHog().groupIdentify({ + groupType: PLATFORM_GROUP_TYPE, + groupKey: platformId, + properties: { + ...pickPlatformGroupPii(properties), + plan: properties.plan, + createdAt: properties.createdAt, + deployment: getDeploymentKind(), + }, + }) + }, async trackPlatform({ platformId, event }: TrackPlatformParams): Promise { if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { return @@ -149,10 +173,30 @@ async function getMetadata() { activepiecesVersion: currentVersion, activepiecesEnvironment: system.get(AppSystemProp.ENVIRONMENT), activepiecesEdition: edition, + deployment: getDeploymentKind(), source_site: 'product', } } +function pickPlatformGroupPii(properties: PlatformGroupProperties): { name?: string } { + if (system.getEdition() !== ApEdition.CLOUD) { + return {} + } + return { name: properties.name } +} + +function getDeploymentKind(): DeploymentKind { + if (system.getEdition() === ApEdition.CLOUD) { + return DeploymentKind.CLOUD + } + if (system.get(AppSystemProp.ENVIRONMENT) === ApEnvironment.DEVELOPMENT) { + return DeploymentKind.DEV + } + return DeploymentKind.SELF_HOSTED +} + +const PLATFORM_GROUP_TYPE = 'platform' + export enum LicenseKeyPostHogEvents { AI_USAGE_PER_RUN = 'ai_usage_per_run', CHAT_MESSAGE = 'chat_message', @@ -242,6 +286,23 @@ type IdentifySignUpParams = { attribution: AttributionParams | undefined } +type AliasIdentityParams = { + identityId: string + userId: UserId + platformId: string +} + +export type PlatformGroupProperties = { + name: string + plan: string | null + createdAt: string +} + +type IdentifyPlatformGroupParams = { + platformId: string + properties: PlatformGroupProperties +} + type TrackPlatformParams = { platformId: string event: TelemetryEvent diff --git a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts index 8e9d553c9407..373441305836 100644 --- a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts +++ b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts @@ -1,4 +1,4 @@ -import { isNil } from '@activepieces/core-utils' +import { isNil, tryCatch } from '@activepieces/core-utils' import { PlatformRole, PrincipalType } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' @@ -33,23 +33,11 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { } } - const key = await jwtUtils.getJwtSecret() - let authRequest: AuthRequestPayload - try { - authRequest = await jwtUtils.decodeAndVerify({ - jwt: authRequestId, - key, - audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, - }) - } - catch { + const { data: authRequest, error } = await tryCatch(() => verifyAuthRequest(authRequestId)) + if (error) { return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid or expired authorization request' }) } - if (authRequest.type !== 'mcp_auth_request') { - return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid authorization request type' }) - } - const code = await mcpOAuthCodeService.create({ clientId: authRequest.clientId, userId, @@ -71,6 +59,34 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { return reply.send({ redirectUrl: redirectUrl.toString() }) }) + + app.post('/v1/mcp-oauth/deny', DenyRequest, async (req, reply) => { + const { data: authRequest, error } = await tryCatch(() => verifyAuthRequest(req.body.authRequestId)) + if (error) { + return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid or expired authorization request' }) + } + + const redirectUrl = new URL(authRequest.redirectUri) + redirectUrl.searchParams.set('error', 'access_denied') + if (authRequest.state) { + redirectUrl.searchParams.set('state', authRequest.state) + } + + return reply.send({ redirectUrl: redirectUrl.toString() }) + }) +} + +async function verifyAuthRequest(authRequestId: string): Promise { + const key = await jwtUtils.getJwtSecret() + const authRequest = await jwtUtils.decodeAndVerify({ + jwt: authRequestId, + key, + audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, + }) + if (authRequest.type !== 'mcp_auth_request') { + throw new Error('Invalid authorization request type') + } + return authRequest } const ApproveRequest = { @@ -86,6 +102,18 @@ const ApproveRequest = { }, } +const DenyRequest = { + config: { + security: securityAccess.publicPlatform([PrincipalType.USER]), + }, + schema: { + tags: ['mcp-oauth'], + body: z.object({ + authRequestId: z.string(), + }), + }, +} + type AuthRequestPayload = { clientId: string redirectUri: string diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 8c9cfaa686b7..4fa1c71567f9 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -11,6 +11,7 @@ import { platformPlanService } from '../ee/platform/platform-plan/platform-plan. import { defaultTheme } from '../flags/theme' import { rejectedPromiseHandler } from '../helper/promise-handler' import { system } from '../helper/system/system' +import { telemetry } from '../helper/telemetry.utils' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { billingProvider } from './billing-provider' @@ -73,7 +74,14 @@ export const platformService = (log: FastifyBaseLogger) => ({ platformId: savedPlatform.id, }) - await platformPlanService(log).onPlatformCreated(savedPlatform.id) + const platformPlan = await platformPlanService(log).onPlatformCreated(savedPlatform.id) + const { error: telemetryError } = await tryCatch(() => telemetry(log).identifyPlatformGroup({ + platformId: savedPlatform.id, + properties: { name: savedPlatform.name, plan: platformPlan.plan ?? null, createdAt: savedPlatform.created }, + })) + if (!isNil(telemetryError)) { + log.warn({ error: telemetryError, platform: { id: savedPlatform.id } }, 'Failed to identify the platform group') + } log.info({ platform: { id: savedPlatform.id }, ownerId }, 'Platform created') return stripFederatedAuth(savedPlatform) @@ -113,7 +121,7 @@ export const platformService = (log: FastifyBaseLogger) => ({ if (invalidatePreviousTokens) { await rotateTokenVersion(identityId) } - await reportSignup({ identityId, user: owner, projectId: personalProject.id, log }) + await reportSignup({ identityId, user: owner, platformId: platform.id, projectId: personalProject.id, log }) const response = await authenticationUtils(log).getProjectAndToken({ userId: owner.id, platformId: platform.id, @@ -301,17 +309,18 @@ async function linkOwnerToPlatform({ ownerId, platformId, identityId, name, inva log, }) if (!isNil(response.projectId)) { - rejectedPromiseHandler(reportSignup({ identityId, user: owner, projectId: response.projectId, log }), log) + rejectedPromiseHandler(reportSignup({ identityId, user: owner, platformId, projectId: response.projectId, log }), log) } return { response, provisioned: true } } -async function reportSignup({ identityId, user, projectId, log }: ReportSignupParams): Promise { +async function reportSignup({ identityId, user, platformId, projectId, log }: ReportSignupParams): Promise { await authenticationUtils(log).sendTelemetry({ identity: await userIdentityService(log).getOneOrFail({ id: identityId }), user, projectId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId, userId: user.id, platformId }), log) } function isSameTokenVersion(current: string | undefined, caller: string | undefined): boolean { @@ -461,6 +470,7 @@ type FinishExistingPlatformParams = { type ReportSignupParams = { identityId: string user: User + platformId: string projectId: string log: FastifyBaseLogger } diff --git a/packages/server/api/src/app/user-invitations/user-invitation.module.ts b/packages/server/api/src/app/user-invitations/user-invitation.module.ts index 05648a7956a7..fec93c87f7ed 100644 --- a/packages/server/api/src/app/user-invitations/user-invitation.module.ts +++ b/packages/server/api/src/app/user-invitations/user-invitation.module.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, assertNotNullOrUndefined, ErrorCode, isNil, Permission, ProjectRole, SeekPage } from '@activepieces/core-utils' -import { InvitationStatus, InvitationType, ListUserInvitationsRequest, Principal, PrincipalType, SendUserInvitationRequest, SERVICE_KEY_SECURITY_OPENAPI, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' +import { InvitationStatus, InvitationType, ListUserInvitationsRequest, Principal, PrincipalType, SendUserInvitationRequest, SERVICE_KEY_SECURITY_OPENAPI, TelemetryEvent, TelemetryEventName, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' import { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -12,6 +12,8 @@ import { platformMustBeOwnedByCurrentUser, platformMustHaveFeatureEnabled, proje import { assertRoleHasPermission } from '../ee/authentication/project-role/rbac-middleware' import { platformPlanService } from '../ee/platform/platform-plan/platform-plan.service' import { projectRoleService } from '../ee/projects/project-role/project-role.service' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { telemetry } from '../helper/telemetry.utils' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { INVITATION_EXPIRY_SECONDS, userInvitationsService } from './user-invitation.service' @@ -64,6 +66,19 @@ const invitationController: FastifyPluginAsyncZod = async (app) => { userInvitation: userInvitationRecord, invitationExpirySeconds: INVITATION_EXPIRY_SECONDS, }) + rejectedPromiseHandler(trackInviteSent({ + principal: request.principal, + platformId, + event: { + name: TelemetryEventName.INVITE_SENT, + payload: { + platformId, + type: type === InvitationType.PLATFORM ? 'platform' : 'project', + role: type === InvitationType.PLATFORM ? request.body.platformRole ?? undefined : projectRole?.name, + }, + }, + log: request.log, + }), request.log) await reply.status(StatusCodes.CREATED).send(invitation) }) @@ -185,6 +200,14 @@ async function assertPrincipalHasPermissionToProject { + if (principal.type === PrincipalType.USER) { + await telemetry(log).trackUser({ userId: principal.id, platformId, event }) + return + } + await telemetry(log).trackPlatform({ platformId, event }) +} + const ListUserInvitationsRequestParams = { config: { security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.SERVICE], { @@ -245,3 +268,10 @@ const UpsertUserInvitationRequestParams = { }, }, } + +type TrackInviteSentParams = { + principal: Principal + platformId: string + event: TelemetryEvent + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/user-invitations/user-invitation.service.ts b/packages/server/api/src/app/user-invitations/user-invitation.service.ts index 71e345be23b1..77ac9d4b3854 100644 --- a/packages/server/api/src/app/user-invitations/user-invitation.service.ts +++ b/packages/server/api/src/app/user-invitations/user-invitation.service.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, apId, assertEqual, assertNotNullOrUndefined, ErrorCode, isNil, SeekPage, spreadIfDefined } from '@activepieces/core-utils' -import { InvitationStatus, InvitationType, PlatformRole, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' +import { InvitationStatus, InvitationType, PlatformRole, TelemetryEventName, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { EntityManager, IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm' @@ -13,6 +13,8 @@ import { domainHelper } from '../helper/domain-helper' import { JwtAudience, jwtUtils } from '../helper/jwt-utils' import { buildPaginator } from '../helper/pagination/build-paginator' import { paginationHelper } from '../helper/pagination/pagination-utils' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { telemetry } from '../helper/telemetry.utils' import { platformService } from '../platform/platform.service' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' @@ -235,9 +237,22 @@ export const userInvitationsService = (log: FastifyBaseLogger) => ({ }, async accept({ invitationId, platformId }: AcceptParams): Promise { const invitation = await this.getOneOrThrow({ id: invitationId, platformId }) + const firstAcceptance = invitation.status !== InvitationStatus.ACCEPTED await repo().update(invitation.id, { status: InvitationStatus.ACCEPTED, }) + if (firstAcceptance) { + rejectedPromiseHandler(telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.INVITE_ACCEPTED, + payload: { + platformId, + type: invitation.type === InvitationType.PLATFORM ? 'platform' : 'project', + }, + }, + }), log) + } const identity = await userIdentityService(log).getIdentityByEmail(invitation.email) if (isNil(identity)) { return { registered: false } diff --git a/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts b/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts index 77d31055e329..03437e16bd1c 100644 --- a/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts @@ -6,6 +6,7 @@ import { FlowTriggerType, FlowVersion, FlowVersionState, + LATEST_FLOW_SCHEMA_VERSION, PackageType, PieceType, PopulatedFlow, @@ -794,6 +795,45 @@ describe('Flow Operations API', () => { const persisted: PopulatedFlow = afterImport?.json() expect(persisted.version.trigger.nextAction).toBeUndefined() }) + + it('imports an old template whose piece version the upgrade register moves forward', async () => { + const ctx = await createTestContext(app!) + + const createResponse = await ctx.post('/v1/flows', { + displayName: 'test flow', + projectId: ctx.project.id, + }, { query: { projectId: ctx.project.id } }) + const flow: PopulatedFlow = createResponse?.json() + + const response = await ctx.post(`/v1/flows/${flow.id}`, { + type: FlowOperationType.IMPORT_FLOW, + request: { + displayName: 'Event handler', + trigger: { + type: FlowTriggerType.PIECE, + name: 'trigger', + displayName: 'Catch Webhook', + valid: true, + lastUpdatedDate: new Date().toISOString(), + settings: { + pieceName: '@activepieces/piece-webhook', + pieceVersion: '0.1.33', + triggerName: 'catch_webhook', + input: { authType: 'none', authFields: {} }, + propertySettings: {}, + }, + }, + schemaVersion: '20', + notes: [], + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body: PopulatedFlow = response?.json() + expect(body.version.schemaVersion).toBe(LATEST_FLOW_SCHEMA_VERSION) + expect(body.version.trigger.type).toBe(FlowTriggerType.PIECE) + expect(body.version.trigger.settings.pieceVersion).not.toBe('0.1.33') + }) }) describe('POST /v1/flows/:id draft creation rollback', () => { diff --git a/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts new file mode 100644 index 000000000000..1ed6972674e4 --- /dev/null +++ b/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts @@ -0,0 +1,113 @@ +import { FastifyInstance } from 'fastify' +import { beforeAll, describe, expect, it } from 'vitest' +import { JwtAudience, jwtUtils } from '../../../../src/app/helper/jwt-utils' +import { MCP_OAUTH_REDIRECT_URI, mcpOAuthTestHelpers } from '../../../helpers/mcp-oauth' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance +let ctx: TestContext + +const STATE = 'client-state-123' + +async function startAuthorization({ state }: { state: string | null } = { state: STATE }): Promise { + const client = await mcpOAuthTestHelpers.registerClient({ app, tokenEndpointAuthMethod: 'none' }) + const { challenge } = mcpOAuthTestHelpers.generatePkce() + + const consent = await app.inject({ + method: 'GET', + url: '/authorize?' + new URLSearchParams({ + client_id: client.client_id, + redirect_uri: MCP_OAUTH_REDIRECT_URI, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'mcp', + ...(state ? { state } : {}), + }).toString(), + }) + expect(consent.statusCode).toBe(302) + + return new URL(String(consent.headers.location), MCP_OAUTH_REDIRECT_URI).searchParams.get('authRequestId') ?? '' +} + +async function signAuthRequest({ expiresInSeconds, type }: { expiresInSeconds: number, type: string }): Promise { + return jwtUtils.sign({ + payload: { + clientId: 'irrelevant', + clientName: 'Irrelevant', + redirectUri: MCP_OAUTH_REDIRECT_URI, + codeChallenge: 'x'.repeat(43), + codeChallengeMethod: 'S256', + state: STATE, + scopes: ['mcp'], + resource: null, + type, + }, + key: await jwtUtils.getJwtSecret(), + expiresInSeconds, + audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, + }) +} + +describe('MCP OAuth deny', () => { + beforeAll(async () => { + app = await setupTestEnvironment() + ctx = await createTestContext(app) + }) + + it('sends the client back with access_denied and its state, and no code', async () => { + const authRequestId = await startAuthorization() + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(200) + const redirect = new URL(denied.json().redirectUrl) + expect(redirect.origin + redirect.pathname).toBe(MCP_OAUTH_REDIRECT_URI) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.get('state')).toBe(STATE) + expect(redirect.searchParams.get('code')).toBeNull() + }) + + it('omits state when the client sent none', async () => { + const authRequestId = await startAuthorization({ state: null }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(200) + const redirect = new URL(denied.json().redirectUrl) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.has('state')).toBe(false) + }) + + it('refuses a tampered request', async () => { + const authRequestId = await startAuthorization() + const [header, payload, signature] = authRequestId.split('.') + const forged = JSON.parse(Buffer.from(payload, 'base64url').toString()) + forged.redirectUri = 'https://attacker.example/callback' + const tampered = [header, Buffer.from(JSON.stringify(forged)).toString('base64url'), signature].join('.') + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId: tampered }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) + + it('refuses an expired request', async () => { + const authRequestId = await signAuthRequest({ expiresInSeconds: -1, type: 'mcp_auth_request' }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) + + it('refuses a signed token that is not an authorization request', async () => { + const authRequestId = await signAuthRequest({ expiresInSeconds: 600, type: 'something_else' }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) +}) diff --git a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts index 62bb065e7982..439b556767d8 100644 --- a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts +++ b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts @@ -18,6 +18,8 @@ vi.mock('../../../../src/app/helper/telemetry.utils', async (importOriginal) => trackProject: vi.fn().mockResolvedValue(undefined), trackIdentity: vi.fn().mockResolvedValue(undefined), trackUser: vi.fn().mockResolvedValue(undefined), + aliasIdentity: vi.fn().mockResolvedValue(undefined), + identifyPlatformGroup: vi.fn().mockResolvedValue(undefined), }), } }) diff --git a/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts b/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts new file mode 100644 index 000000000000..c9214add7bff --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts @@ -0,0 +1,125 @@ +import { TelemetryEventName } from '@activepieces/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockTrackUser, + mockTrackIdentity, + mockGetIdentityByEmail, + mockGetOneByIdentityAndPlatform, + mockSelectCloudSignInPlatformId, +} = vi.hoisted(() => ({ + mockTrackUser: vi.fn().mockResolvedValue(undefined), + mockTrackIdentity: vi.fn().mockResolvedValue(undefined), + mockGetIdentityByEmail: vi.fn(), + mockGetOneByIdentityAndPlatform: vi.fn(), + mockSelectCloudSignInPlatformId: vi.fn(), +})) + +vi.mock('../../../../src/app/authentication/lib/turnstile', () => ({ + turnstile: { assertSolved: vi.fn().mockResolvedValue(undefined) }, +})) + +vi.mock('../../../../src/app/authentication/lib/zerobounce', () => ({ + zerobounce: { maySignUp: vi.fn().mockResolvedValue(true) }, +})) + +vi.mock('../../../../src/app/authentication/otp/otp-service', () => ({ + otpService: vi.fn(() => ({ createAndSend: vi.fn().mockResolvedValue(undefined) })), +})) + +vi.mock('../../../../src/app/authentication/user-identity/user-identity-service', () => ({ + userIdentityService: vi.fn(() => ({ + getIdentityByEmail: mockGetIdentityByEmail, + create: vi.fn(), + })), +})) + +vi.mock('../../../../src/app/user/user-service', () => ({ + userService: vi.fn(() => ({ getOneByIdentityAndPlatform: mockGetOneByIdentityAndPlatform })), +})) + +vi.mock('../../../../src/app/authentication/authentication.service', () => ({ + authenticationService: vi.fn(() => ({ selectCloudSignInPlatformId: mockSelectCloudSignInPlatformId })), +})) + +vi.mock('../../../../src/app/helper/telemetry.utils', () => ({ + telemetry: vi.fn(() => ({ trackUser: mockTrackUser, trackIdentity: mockTrackIdentity })), +})) + +import { passwordlessAuthService } from '../../../../src/app/authentication/passwordless-auth.service' + +const mockLog = { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), + child: vi.fn(), fatal: vi.fn(), trace: vi.fn(), silent: vi.fn(), level: 'info', +} as unknown as Parameters[0] + +const EMAIL = 'someone@example.com' +const IDENTITY = { id: 'identity-1', email: EMAIL } + +async function requestCodeAndFlush(): Promise { + await passwordlessAuthService(mockLog).requestCode({ + email: EMAIL, + platformId: null, + captchaToken: 'token', + remoteIp: '127.0.0.1', + }) + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('passwordless auth telemetry distinct id', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIdentityByEmail.mockResolvedValue(IDENTITY) + mockSelectCloudSignInPlatformId.mockResolvedValue('platform-1') + }) + + it('keys the event on the identity id while no user exists yet', async () => { + mockGetOneByIdentityAndPlatform.mockResolvedValue(null) + + await requestCodeAndFlush() + + expect(mockTrackUser).not.toHaveBeenCalled() + expect(mockTrackIdentity).toHaveBeenCalledWith({ + identityId: 'identity-1', + platformId: 'platform-1', + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) + + it('switches to the user id as soon as a user exists for identity and platform', async () => { + mockGetOneByIdentityAndPlatform.mockResolvedValue({ id: 'user-1' }) + + await requestCodeAndFlush() + + expect(mockTrackIdentity).not.toHaveBeenCalled() + expect(mockTrackUser).toHaveBeenCalledWith({ + userId: 'user-1', + platformId: 'platform-1', + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) + + it('keys the event on the identity id when no platform can be resolved', async () => { + mockSelectCloudSignInPlatformId.mockResolvedValue(null) + mockGetOneByIdentityAndPlatform.mockResolvedValue({ id: 'user-1' }) + + await requestCodeAndFlush() + + expect(mockGetOneByIdentityAndPlatform).not.toHaveBeenCalled() + expect(mockTrackUser).not.toHaveBeenCalled() + expect(mockTrackIdentity).toHaveBeenCalledWith({ + identityId: 'identity-1', + platformId: null, + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts index 6183238c989e..3b8279a9ad24 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts @@ -1,15 +1,18 @@ import { AIProviderName } from '@activepieces/core-utils' +import { AIProviderModelType } from '@activepieces/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetChatProvider, mockGetConfigOrThrow } = vi.hoisted(() => ({ +const { mockGetChatProvider, mockGetConfigOrThrow, mockListModels } = vi.hoisted(() => ({ mockGetChatProvider: vi.fn().mockResolvedValue(null), mockGetConfigOrThrow: vi.fn(), + mockListModels: vi.fn().mockResolvedValue([]), })) vi.mock('../../../../../src/app/ai/ai-provider-service', () => ({ aiProviderService: () => ({ getChatProvider: mockGetChatProvider, getConfigOrThrow: mockGetConfigOrThrow, + listModels: mockListModels, }), })) @@ -26,12 +29,15 @@ const scope = { type: 'project', projectId: 'project-1' } as const describe('resolveFastModel', () => { beforeEach(() => { + mockListModels.mockClear().mockResolvedValue([]) mockGetChatProvider.mockClear().mockResolvedValue(null) mockGetConfigOrThrow.mockClear().mockResolvedValue({ provider: AIProviderName.OPENROUTER, configId: 'config-1', auth: { apiKey: 'k' }, config: {}, + modelScope: 'all', + modelIds: [], }) }) @@ -62,4 +68,117 @@ describe('resolveFastModel', () => { expect(model).toMatchObject({ credentials: { provider: AIProviderName.ANTHROPIC }, modelId: 'claude-haiku-4-5' }) }) + + describe('on a provider we ship no curated model list for', () => { + beforeEach(() => { + mockGetConfigOrThrow.mockResolvedValue({ + provider: AIProviderName.BEDROCK, + configId: 'config-1', + auth: { accessKeyId: 'a', secretAccessKey: 's' }, + config: { region: 'us-east-1' }, + modelScope: 'all', + modelIds: [], + }) + }) + + it('fills a configured action\'s inputs on a fast model the key actually serves', async () => { + mockListModels.mockResolvedValue([ + { id: 'amazon.titan-text-express-v1', name: 'Titan', type: AIProviderModelType.TEXT }, + { id: 'eu.anthropic.claude-haiku-4-5-20251001-v1:0', name: 'Haiku', type: AIProviderModelType.TEXT }, + ]) + + const model = await agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.BEDROCK, scope, log }) + + expect(model).toMatchObject({ credentials: { provider: AIProviderName.BEDROCK }, modelId: 'eu.anthropic.claude-haiku-4-5-20251001-v1:0' }) + }) + + it('needs nothing from the worker, so an in-flight run on an older one still resolves', async () => { + mockListModels.mockResolvedValue([{ id: 'amazon.titan-text-express-v1', name: 'Titan', type: AIProviderModelType.TEXT }]) + + await expect(agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.BEDROCK, scope, log })).resolves.toMatchObject({ modelId: 'amazon.titan-text-express-v1' }) + }) + + it('still refuses a key that serves no text model at all', async () => { + mockListModels.mockResolvedValue([]) + + await expect(agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.BEDROCK, scope, log })).rejects.toMatchObject({ + error: { code: 'ENTITY_NOT_FOUND' }, + }) + }) + }) + + it('never asks the provider when our own catalogue already names a fast model', async () => { + const model = await agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.OPENROUTER, scope, log }) + + expect(model).toMatchObject({ modelId: 'anthropic/claude-haiku-4.5' }) + expect(mockListModels).not.toHaveBeenCalled() + }) +}) + +describe('resolveModelId', () => { + const bedrockKey = { provider: AIProviderName.BEDROCK, configId: 'config-1', auth: { accessKeyId: 'a', secretAccessKey: 's' }, config: { region: 'us-east-1' }, modelScope: 'all' as const, modelIds: [] } + const text = (id: string) => ({ id, name: id, type: AIProviderModelType.TEXT }) + + const resolve = ({ selectedModel }: { selectedModel: string | null }) => + agentHelpers.resolveModelId({ platformId, providerConfig: bedrockKey, selectedModel, scope, log }) + + beforeEach(() => { + mockListModels.mockClear().mockResolvedValue([]) + }) + + it('never asks the provider when our own catalogue already answers', async () => { + const anthropicKey = { ...bedrockKey, provider: AIProviderName.ANTHROPIC, config: {} } + + await expect(agentHelpers.resolveModelId({ platformId, providerConfig: anthropicKey, selectedModel: 'smart', scope, log })).resolves.toBe('claude-sonnet-4-6') + expect(mockListModels).not.toHaveBeenCalled() + }) + + it('runs a chat tier on a model the key actually serves', async () => { + mockListModels.mockResolvedValue([text('amazon.titan-text-express-v1')]) + + await expect(resolve({ selectedModel: 'smart' })).resolves.toBe('amazon.titan-text-express-v1') + expect(mockListModels).toHaveBeenCalledWith({ platformId, provider: AIProviderName.BEDROCK, scope, configId: 'config-1' }) + }) + + it('keeps the tier meaningful when the provider hosts the model that tier names', async () => { + mockListModels.mockResolvedValue([ + text('amazon.titan-text-express-v1'), + text('global.anthropic.claude-haiku-4-5-20251001-v1:0'), + text('global.anthropic.claude-sonnet-4-6-20250101-v1:0'), + ]) + + await expect(resolve({ selectedModel: 'fast' })).resolves.toBe('global.anthropic.claude-haiku-4-5-20251001-v1:0') + await expect(resolve({ selectedModel: 'smart' })).resolves.toBe('global.anthropic.claude-sonnet-4-6-20250101-v1:0') + }) + + it('honours a model the user picked over the tier default', async () => { + mockListModels.mockResolvedValue([text('amazon.titan-text-express-v1'), text('mistral.mistral-large-2407-v1:0')]) + + await expect(resolve({ selectedModel: 'mistral.mistral-large-2407-v1:0' })).resolves.toBe('mistral.mistral-large-2407-v1:0') + }) + + it('keeps the run on the model it already resolved rather than picking a second unverified one', async () => { + mockListModels.mockResolvedValue([ + { id: 'eu.anthropic.claude-haiku-4-5-20251001-v1:0', name: 'Haiku', type: AIProviderModelType.TEXT }, + ]) + + await expect(agentHelpers.resolveFastModelId({ platformId, providerConfig: bedrockKey, scope, fallbackModelId: 'eu.anthropic.claude-sonnet-4-6', log })).resolves.toBe('eu.anthropic.claude-sonnet-4-6') + expect(mockListModels).not.toHaveBeenCalled() + }) + + it('refuses a borrowed model the admin has since scoped off the key', async () => { + const scopedKey = { ...bedrockKey, modelScope: 'selected' as const, modelIds: ['eu.anthropic.claude-haiku-4-5-20251001-v1:0'] } + mockListModels.mockResolvedValue([ + { id: 'eu.anthropic.claude-haiku-4-5-20251001-v1:0', name: 'Haiku', type: AIProviderModelType.TEXT }, + ]) + + await expect(agentHelpers.resolveFastModelId({ platformId, providerConfig: scopedKey, scope, fallbackModelId: 'eu.anthropic.claude-sonnet-4-6', log })) + .resolves.toBe('eu.anthropic.claude-haiku-4-5-20251001-v1:0') + }) + + it('never answers with a model the key does not serve', async () => { + mockListModels.mockResolvedValue([{ id: 'amazon.titan-image-generator-v1', name: 'Titan Image', type: AIProviderModelType.IMAGE }]) + + await expect(resolve({ selectedModel: 'smart' })).rejects.toMatchObject({ error: { code: 'ENTITY_NOT_FOUND' } }) + }) }) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts index cad4c7fb51e6..adc4cd1d1df8 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts @@ -106,12 +106,6 @@ describe('resolveModelIdForProvider', () => { expect(error).toBeInstanceOf(ActivepiecesError) expect(String(error)).not.toContain('claude') }) - - it('resolves the fast round to a model the provider offers', () => { - expect(agentModelResolution.resolveFastModelId({ provider: AIProviderName.ANTHROPIC })).toBe('claude-haiku-4-5') - expect(agentModelResolution.resolveFastModelId({ provider: AIProviderName.OPENAI })).toBe('gpt-5.5') - expect(agentModelResolution.resolveFastModelId({ provider: AIProviderName.ACTIVEPIECES })).toBe('anthropic/claude-haiku-4.5') - }) }) describe('resolveModelIdForAnalytics', () => { diff --git a/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts b/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts index 4517f0d0431f..0d1c0bcd4017 100644 --- a/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts +++ b/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts @@ -30,6 +30,12 @@ vi.mock('../../../../../src/app/ee/platform/platform-plan/platform-plan.service' }), })) +vi.mock('../../../../../src/app/ee/platform/platform-plan/platform-plan-telemetry', () => ({ + platformPlanTelemetry: () => ({ + onEntitlementsRefreshed: vi.fn(), + }), +})) + vi.mock('../../../../../src/app/database/redis-connections', () => ({ distributedLock: () => ({ runExclusive: async ({ fn }: { fn: () => Promise }) => fn(), diff --git a/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts b/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts new file mode 100644 index 000000000000..b3e5e20886e1 --- /dev/null +++ b/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts @@ -0,0 +1,118 @@ +import { + FlowTriggerType, + FlowVersionState, + LATEST_FLOW_SCHEMA_VERSION, +} from '@activepieces/shared' +import type { FlowTrigger, FlowVersion } from '@activepieces/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { migrateFlowVersionTemplate } from '../../../../../src/app/flows/flow-version/migrations' +import { pieceUpgradeService } from '../../../../../src/app/flows/flow-version/piece-upgrade.service' +import { system } from '../../../../../src/app/helper/system/system' + +const mockFlowFindOneBy = vi.fn() +const mockSendUserEvent = vi.fn() + +vi.mock('../../../../../src/app/flows/flow-version/piece-upgrade-register', () => ({ + pieceUpgradeRegister: { + lookup: vi.fn(async ({ pieceName, pieceVersion }: { pieceName: string, pieceVersion: string }) => + pieceName === WEBHOOK_PIECE_NAME && pieceVersion === OLD_WEBHOOK_VERSION + ? { target: UPGRADED_WEBHOOK_VERSION } + : undefined), + resolveDecision: vi.fn(({ entry }: { entry: { target: string } }) => ({ outcome: 'upgraded', toVersion: entry.target })), + }, +})) + +vi.mock('../../../../../src/app/flows/flow/flow.repo', () => ({ + flowRepo: () => ({ + findOneBy: mockFlowFindOneBy, + }), +})) + +vi.mock('../../../../../src/app/project/project-service', () => ({ + projectService: vi.fn(() => ({ + getPlatformId: vi.fn().mockResolvedValue('platform-1'), + })), +})) + +vi.mock('../../../../../src/app/helper/application-events', () => ({ + applicationEvents: vi.fn(() => ({ + sendUserEvent: mockSendUserEvent, + })), +})) + +describe('pieceUpgradeService.migrateFlowVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlowFindOneBy.mockResolvedValue(null) + }) + + it('migrates a template that has no stored flow without sending an upgrade audit event', async () => { + const migrated = await migrateFlowVersionTemplate({ + displayName: 'Handler flow', + trigger: webhookTrigger(), + schemaVersion: '20', + notes: [], + valid: false, + }) + + expect(migrated.schemaVersion).toBe(LATEST_FLOW_SCHEMA_VERSION) + expect(pieceVersionOf(migrated.trigger)).toBe(UPGRADED_WEBHOOK_VERSION) + expect(mockSendUserEvent).not.toHaveBeenCalled() + }) + + it('sends an upgrade audit event for a stored flow version resolved by its flow id', async () => { + mockFlowFindOneBy.mockResolvedValue({ id: 'flow-1', projectId: 'project-1' }) + + const result = await pieceUpgradeService(system.globalLogger()).migrateFlowVersion({ flowVersion: storedFlowVersion() }) + + expect(pieceVersionOf(result.flowVersion.trigger)).toBe(UPGRADED_WEBHOOK_VERSION) + expect(mockSendUserEvent).toHaveBeenCalledWith( + { platformId: 'platform-1', projectId: 'project-1' }, + expect.objectContaining({ data: expect.objectContaining({ flowId: 'flow-1', flowVersionId: 'fv-1' }) }), + ) + }) +}) + +function webhookTrigger(): FlowTrigger { + return { + name: 'trigger', + valid: true, + displayName: 'Catch Webhook', + lastUpdatedDate: '2026-01-01T00:00:00Z', + type: FlowTriggerType.PIECE, + settings: { + pieceName: WEBHOOK_PIECE_NAME, + pieceVersion: OLD_WEBHOOK_VERSION, + triggerName: 'catch_webhook', + input: {}, + propertySettings: {}, + }, + } +} + +function storedFlowVersion(): FlowVersion { + return { + id: 'fv-1', + created: '2026-01-01T00:00:00Z', + updated: '2026-01-01T00:00:00Z', + flowId: 'flow-1', + displayName: 'Handler flow', + trigger: webhookTrigger(), + updatedBy: null, + valid: true, + schemaVersion: '23', + agentIds: [], + state: FlowVersionState.DRAFT, + connectionIds: [], + backupFiles: null, + notes: [], + } +} + +function pieceVersionOf(trigger: FlowTrigger): string | undefined { + return trigger.type === FlowTriggerType.PIECE ? trigger.settings.pieceVersion : undefined +} + +const WEBHOOK_PIECE_NAME = '@activepieces/piece-webhook' +const OLD_WEBHOOK_VERSION = '0.1.33' +const UPGRADED_WEBHOOK_VERSION = '0.1.36' diff --git a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts index d97f963169ae..594eebb3f5f9 100644 --- a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts +++ b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts @@ -1,14 +1,15 @@ -import { ApEdition, TelemetryEventName } from '@activepieces/shared' +import { ApEdition, TelemetryEvent, TelemetryEventName } from '@activepieces/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCapture, mockGetEdition, mockIsProductTelemetryEnabled } = vi.hoisted(() => ({ - mockCapture: vi.fn(), +const { mockCapture, mockAlias, mockGetEdition, mockIsProductTelemetryEnabled } = vi.hoisted(() => ({ + mockCapture: vi.fn<(message: CapturedMessage) => void>(), + mockAlias: vi.fn(), mockGetEdition: vi.fn(), mockIsProductTelemetryEnabled: vi.fn().mockResolvedValue(true), })) vi.mock('posthog-node', () => ({ - PostHog: vi.fn(() => ({ capture: mockCapture, identify: vi.fn(), flush: vi.fn(), shutdown: vi.fn() })), + PostHog: vi.fn(() => ({ capture: mockCapture, alias: mockAlias, identify: vi.fn(), groupIdentify: vi.fn(), flush: vi.fn(), shutdown: vi.fn() })), })) vi.mock('../../../../src/app/platform/platform-configuration.service', () => ({ @@ -18,11 +19,11 @@ vi.mock('../../../../src/app/platform/platform-configuration.service', () => ({ })) vi.mock('../../../../src/app/platform/platform.service', () => ({ - platformService: vi.fn(() => ({ getOneOrThrow: vi.fn() })), + platformService: vi.fn(() => ({ getOneOrThrow: vi.fn().mockResolvedValue({ ownerId: 'owner-1' }) })), })) vi.mock('../../../../src/app/project/project-service', () => ({ - projectService: vi.fn(() => ({ getOne: vi.fn() })), + projectService: vi.fn(() => ({ getOne: vi.fn().mockResolvedValue({ id: 'project-1', ownerId: 'owner-1', platformId: 'platform-1' }) })), })) vi.mock('../../../../src/app/helper/system/system', () => ({ @@ -60,9 +61,35 @@ const trackFlowCreated = (edition: ApEdition) => { }) } +const trackPlanUpgraded = (edition: ApEdition) => { + mockGetEdition.mockReturnValue(edition) + return telemetry(mockLog).trackPlatform({ + platformId: 'platform-1', + event: { + name: TelemetryEventName.PLAN_CHANGED, + payload: { platformId: 'platform-1', plan: 'plus', previousPlan: 'free' }, + }, + }) +} + +const CLOUD_ONLY_BILLING_AND_ONBOARDING_EVENTS: TelemetryEvent[] = [ + { name: TelemetryEventName.ONBOARDING_COMPLETED, payload: { userId: 'user-1', platformId: 'platform-1' } }, + { name: TelemetryEventName.CHECKOUT_STARTED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.PLAN_CHANGED, payload: { platformId: 'platform-1', plan: 'plus', previousPlan: 'free' } }, + { name: TelemetryEventName.PLAN_CANCELLED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.PLAN_REACTIVATED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.TRIAL_STARTED, payload: { platformId: 'platform-1', plan: 'plus', trialEndsAt: '2030-01-01T00:00:00.000Z' } }, + { name: TelemetryEventName.SALES_HANDOFF_CLICKED, payload: { surface: 'plan-selector' } }, +] + +const capturedEventNames = (): string[] => mockCapture.mock.calls.map(([message]) => message.event) + +const capturedDistinctIds = (): string[] => mockCapture.mock.calls.map(([message]) => message.distinctId) + describe('cloud-only telemetry events', () => { beforeEach(() => { mockCapture.mockClear() + mockAlias.mockClear() mockIsProductTelemetryEnabled.mockResolvedValue(true) }) @@ -70,7 +97,7 @@ describe('cloud-only telemetry events', () => { await trackSignedIn(ApEdition.CLOUD) expect(mockCapture).toHaveBeenCalledTimes(1) - expect(mockCapture.mock.calls[0][0].event).toBe(TelemetryEventName.SIGNED_IN) + expect(capturedEventNames()).toEqual([TelemetryEventName.SIGNED_IN]) }) it.each([ApEdition.COMMUNITY, ApEdition.ENTERPRISE])( @@ -82,13 +109,28 @@ describe('cloud-only telemetry events', () => { }, ) + it('should send a billing event on cloud attributed to the platform owner', async () => { + await trackPlanUpgraded(ApEdition.CLOUD) + + expect(capturedEventNames()).toEqual([TelemetryEventName.PLAN_CHANGED]) + expect(capturedDistinctIds()).toEqual(['owner-1']) + }) + + it.each(CLOUD_ONLY_BILLING_AND_ONBOARDING_EVENTS)('should treat $name as cloud-only', async (event) => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).trackUser({ userId: 'user-1', platformId: 'platform-1', event }) + + expect(mockCapture).not.toHaveBeenCalled() + }) + it.each([ApEdition.CLOUD, ApEdition.COMMUNITY, ApEdition.ENTERPRISE])( 'should still send a product event on %s', async (edition) => { await trackFlowCreated(edition) expect(mockCapture).toHaveBeenCalledTimes(1) - expect(mockCapture.mock.calls[0][0].event).toBe(TelemetryEventName.CREATED_FLOW) + expect(capturedEventNames()).toEqual([TelemetryEventName.CREATED_FLOW]) }, ) @@ -99,4 +141,76 @@ describe('cloud-only telemetry events', () => { expect(mockCapture).not.toHaveBeenCalled() }) + + it('should stamp every event with the deployment kind', async () => { + await trackFlowCreated(ApEdition.CLOUD) + + expect(mockCapture.mock.calls[0][0].properties.deployment).toBe('cloud') + }) +}) + +describe('identity-level tracking', () => { + beforeEach(() => { + mockCapture.mockClear() + mockAlias.mockClear() + mockIsProductTelemetryEnabled.mockResolvedValue(true) + }) + + it('should send platform-less identity events with the identity id as distinct_id', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: null, + event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity: true } }, + }) + + expect(capturedDistinctIds()).toEqual(['identity-1']) + }) + + it('should keep the identity id as distinct_id when a platform is known', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: 'platform-1', + event: { name: TelemetryEventName.EMAIL_CODE_VERIFIED, payload: { needsNameStep: false } }, + }) + + expect(capturedDistinctIds()).toEqual(['identity-1']) + }) + + it('should drop platform-less identity events outside cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: null, + event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity: true } }, + }) + + expect(mockCapture).not.toHaveBeenCalled() + }) + + it('should alias the identity onto the user on cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).aliasIdentity({ identityId: 'identity-1', userId: 'user-1', platformId: 'platform-1' }) + + expect(mockAlias).toHaveBeenCalledWith({ distinctId: 'user-1', alias: 'identity-1' }) + }) + + it('should not alias outside cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).aliasIdentity({ identityId: 'identity-1', userId: 'user-1', platformId: 'platform-1' }) + + expect(mockAlias).not.toHaveBeenCalled() + }) }) + +type CapturedMessage = { + event: string + distinctId: string + properties: Record +} diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts index 22379ffb3bdc..8327dd29d309 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts @@ -1,5 +1,5 @@ import { AIProviderName, ErrorCode, formatPieceError, isNil, isObject, isProviderBillingError, isTransientProviderError, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' -import { agentAiUtils, ContentPartLike } from '@activepieces/server-utils' +import { agentAiUtils, ContentPartLike, modelCatalog } from '@activepieces/server-utils' import { AgentPhase, AgentRunSource, agentToolClassification, agentToolPhases, aiProviderUtils, apErrorOf, PersistedAgentPart } from '@activepieces/shared' import { APICallError, generateText, isLoopFinished, isStepCount, LanguageModel, LanguageModelUsage, ModelMessage, NoSuchToolError, RetryError, StepResultPerformance, StopCondition, streamText, ToolExecutionOptions, ToolSet } from 'ai' @@ -74,10 +74,12 @@ export async function runAgentTurn({ model, fastModel, provider, systemPrompt, m let lastFinishReason = '' let budgetExceeded = false + const maxOutputTokens = await affordableOutputTokens({ provider, modelIds: [modelId, fastModelId], thinkingBudget: tier.thinkingBudget }) + const runStreamAttempt = (attemptMessages: ModelMessage[]): ReturnType => streamText({ model, maxRetries: 3, - maxOutputTokens: tier.thinkingBudget + MAX_RESPONSE_OUTPUT_TOKENS, + maxOutputTokens, abortSignal, instructions: agentAiUtils.buildSystemPromptWithCaching({ systemPrompt, provider }), messages: agentAiUtils.stripThinkingBlocks(attemptMessages, provider), @@ -385,6 +387,17 @@ export function looksEmptyResultText(text: string): boolean { return /"found"\s*:\s*false|\bempty result\b|no results matched|"result"\s*:\s*\[\s*\]|"results"\s*:\s*\[\s*\]/i.test(text) } +export async function affordableOutputTokens({ provider, modelIds, thinkingBudget }: { provider: AIProviderName, modelIds: (string | undefined)[], thinkingBudget: number }): Promise { + const catalog = await modelCatalog.load() + const ceilings = modelIds.map((modelId) => isNil(modelId) ? undefined : catalog.lookup({ provider, modelId })?.maxOutputTokens) + return clampOutputTokens({ thinkingBudget, ceilings }) +} + +export function clampOutputTokens({ thinkingBudget, ceilings }: { thinkingBudget: number, ceilings: (number | undefined)[] }): number { + const known = ceilings.filter((ceiling) => !isNil(ceiling)) + return Math.min(thinkingBudget + MAX_RESPONSE_OUTPUT_TOKENS, ...known) +} + function runawayTokenCeiling(provider: AIProviderName): number { return aiProviderUtils.getMaxContextTokens({ provider }) * RUNAWAY_TURN_CONTEXT_MULTIPLE } diff --git a/packages/server/worker/src/lib/worker.ts b/packages/server/worker/src/lib/worker.ts index 001030a3d511..ae2cf31557df 100644 --- a/packages/server/worker/src/lib/worker.ts +++ b/packages/server/worker/src/lib/worker.ts @@ -2,7 +2,7 @@ import { createServer } from 'http' import os from 'os' import { ActivepiecesError, isNil, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { ACTION_RUN_CACHE_ACTIVE_WINDOW_MS, ACTION_RUN_CACHE_FIRST_SWEEP_DELAY_MS, ACTION_RUN_CACHE_SWEEP_INTERVAL_MS, actionRunCache, cacheUtils, createResolver, createSandboxRuntime, Runtime } from '@activepieces/sandbox' -import { aiCostReporter, createLogger, systemUsage, wideEvent } from '@activepieces/server-utils' +import { aiCostReporter, createLogger, modelCatalog, systemUsage, wideEvent } from '@activepieces/server-utils' import { ApEdition, ApiToWorkerContract, ConsumeJobRequest, createNotifyServer, createRpcClient, EngineResponseStatus, ExecutionMode, JobData, LONG_RUNNING_RPC_METHODS, SandboxInformation, WebsocketServerEvent, WorkerJobType, WorkerMachineHealthcheckRequest, WorkerProps, WorkerSettingsResponse, WorkerToApiContract } from '@activepieces/shared' import { nanoid } from 'nanoid' import { io, Socket } from 'socket.io-client' @@ -61,6 +61,7 @@ let cacheSweepFirstRunTimeout: NodeJS.Timeout | null = null export const worker = { async start({ apiUrl, socketUrl, workerToken, withHealthServer = false }: WorkerStartParams): Promise { versionChecker.assertReleaseReadable() + modelCatalog.load().catch(() => undefined) const workerGroupId = system.get(WorkerSystemProp.WORKER_GROUP_ID) const projectWorker = system.getBoolean(WorkerSystemProp.PROJECT_WORKER) ?? true socket = io(socketUrl.url, { diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/output-token-ceiling.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/output-token-ceiling.test.ts new file mode 100644 index 000000000000..110d145c8810 --- /dev/null +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/output-token-ceiling.test.ts @@ -0,0 +1,63 @@ +import { AIProviderName } from '@activepieces/core-utils' +import { describe, expect, it, vi } from 'vitest' + +const { mockLoad } = vi.hoisted(() => ({ mockLoad: vi.fn() })) + +vi.mock('@activepieces/server-utils', async (importOriginal) => ({ + ...(await importOriginal>()), + modelCatalog: { load: mockLoad }, +})) + +const { affordableOutputTokens } = await import('../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn') + +const catalogOf = (byId: Record) => ({ + lookup: ({ modelId }: { modelId: string }) => { + const stripped = modelId.replace(/^(us|eu|apac|global)\./, '') + const maxOutputTokens = byId[stripped] + return maxOutputTokens === undefined ? undefined : { maxOutputTokens } + }, +}) + +describe('affordableOutputTokens', () => { + const SMART_TIER_THINKING = 10_000 + + it('reads through a region-prefixed Bedrock id, which is how the picker spells them', async () => { + mockLoad.mockResolvedValue(catalogOf({ 'amazon.nova-pro-v1:0': 10_000 })) + + await expect(affordableOutputTokens({ + provider: AIProviderName.BEDROCK, + modelIds: ['eu.amazon.nova-pro-v1:0'], + thinkingBudget: SMART_TIER_THINKING, + })).resolves.toBe(10_000) + }) + + it('keeps the turn inside the smaller ceiling when the fast round runs a different model', async () => { + mockLoad.mockResolvedValue(catalogOf({ 'amazon.nova-pro-v1:0': 10_000, 'amazon.nova-micro-v1:0': 5_000 })) + + await expect(affordableOutputTokens({ + provider: AIProviderName.BEDROCK, + modelIds: ['eu.amazon.nova-pro-v1:0', 'eu.amazon.nova-micro-v1:0'], + thinkingBudget: SMART_TIER_THINKING, + })).resolves.toBe(5_000) + }) + + it('leaves the budget alone for a model the catalog has never heard of', async () => { + mockLoad.mockResolvedValue(catalogOf({})) + + await expect(affordableOutputTokens({ + provider: AIProviderName.BEDROCK, + modelIds: ['eu.some.brand-new-model-v9:0'], + thinkingBudget: SMART_TIER_THINKING, + })).resolves.toBe(42_000) + }) + + it('does not shrink a turn because the catalog was unreachable', async () => { + mockLoad.mockResolvedValue({ lookup: () => undefined }) + + await expect(affordableOutputTokens({ + provider: AIProviderName.BEDROCK, + modelIds: ['eu.amazon.nova-pro-v1:0', undefined], + thinkingBudget: SMART_TIER_THINKING, + })).resolves.toBe(42_000) + }) +}) diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts index 3a945f1625ee..0b439cf03cd8 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts @@ -3,7 +3,7 @@ import { AgentRunSource } from '@activepieces/shared' import { APICallError, RetryError } from 'ai' import { describe, expect, it } from 'vitest' -import { classifyAgentRunError, firstStepUsesFastModel, isTransientFailureText, jsonInputFrom, looksEmptyResultText } from '../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn' +import { clampOutputTokens, classifyAgentRunError, firstStepUsesFastModel, isTransientFailureText, jsonInputFrom, looksEmptyResultText } from '../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn' function apiError({ statusCode, message, responseBody }: { statusCode: number, message: string, responseBody?: string }): APICallError { return new APICallError({ message, url: 'https://provider.test/v1/chat', requestBodyValues: {}, statusCode, responseBody }) @@ -16,6 +16,12 @@ describe('isTransientFailureText', () => { } }) + it('does not mask an actionable provider error just because it says to try again', () => { + const bedrockSetupRequired = 'Model use case details have not been submitted for this account. Fill out the Anthropic use case details form before using the model. If you have already filled out the form, try again in 15 minutes.' + + expect(isTransientFailureText(bedrockSetupRequired)).toBe(false) + }) + it('does not flag permanent errors (4xx validation/auth)', () => { for (const t of ['❌ Cannot run action: missing required field channel', '❌ 401 unauthorized', '❌ 400 bad request: invalid email']) { expect(isTransientFailureText(t), t).toBe(false) @@ -196,3 +202,23 @@ describe('jsonInputFrom', () => { } }) }) + +describe('clampOutputTokens', () => { + const SMART_TIER_THINKING = 10_000 + + it('asks for the full budget when no model declares a ceiling', () => { + expect(clampOutputTokens({ thinkingBudget: SMART_TIER_THINKING, ceilings: [undefined, undefined] })).toBe(42_000) + }) + + it('never asks a model for more than it accepts', () => { + expect(clampOutputTokens({ thinkingBudget: SMART_TIER_THINKING, ceilings: [10_000] })).toBe(10_000) + }) + + it('respects the smaller ceiling when the turn spans two models', () => { + expect(clampOutputTokens({ thinkingBudget: SMART_TIER_THINKING, ceilings: [64_000, 8_192] })).toBe(8_192) + }) + + it('leaves a generous ceiling alone rather than raising the ask to meet it', () => { + expect(clampOutputTokens({ thinkingBudget: SMART_TIER_THINKING, ceilings: [200_000] })).toBe(42_000) + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 71bafc1268dd..49b6e8b3c3d0 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -949,6 +949,9 @@ "Build, test, and manage automations": "Build, test, and manage automations", "Use connections and execute flows": "Use connections and execute flows", "Authorization failed. Please try again.": "Authorization failed. Please try again.", + "This request has expired. Go back to {client} and start the connection again.": "This request has expired. Go back to {client} and start the connection again.", + "Signed in as {email}": "Signed in as {email}", + "Switch account": "Switch account", "Deny": "Deny", "Authorize": "Authorize", "Unknown app": "Unknown app", @@ -2878,6 +2881,15 @@ "Searched the step picker": "Searched the step picker", "MCP server connected": "MCP server connected", "MCP tool called": "MCP tool called", + "Onboarding completed": "Onboarding completed", + "Invitation sent": "Invitation sent", + "Invitation accepted": "Invitation accepted", + "Checkout started": "Checkout started", + "Plan changed": "Plan changed", + "Plan cancelled": "Plan cancelled", + "Plan reactivated": "Plan reactivated", + "Trial started": "Trial started", + "Contacted sales": "Contacted sales", "Accounts and sign-in": "Accounts and sign-in", "Emailed sign-in codes": "Emailed sign-in codes", "Flows and the builder": "Flows and the builder", diff --git a/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx b/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx index 45b2bf49d19c..f829eff9906b 100644 --- a/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx +++ b/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx @@ -82,7 +82,6 @@ const PublishFlowReminderWidget = () => { const { mutateAsync: publish } = flowHooks.useChangeFlowStatus({ flowId: flow.id, change: 'publish', - requiresApproval, onSuccess: (updatedFlow: PopulatedFlow) => { setFlow(updatedFlow); setVersion(updatedFlow.version); diff --git a/packages/web/src/app/routes/agents/id/agent-chat-view.tsx b/packages/web/src/app/routes/agents/id/agent-chat-view.tsx index 8a1a4d510988..97b512e83d8b 100644 --- a/packages/web/src/app/routes/agents/id/agent-chat-view.tsx +++ b/packages/web/src/app/routes/agents/id/agent-chat-view.tsx @@ -12,7 +12,7 @@ type AgentChatViewProps = { agent: Agent; conversationsOpen: boolean; openedConversationId: string | undefined; - freshConversations: number; + chatSessionKey: string; footerNote: string; onSelectConversation: (conversationId: string) => void; onNewConversation: () => void; @@ -25,7 +25,7 @@ export const AgentChatView = ({ agent, conversationsOpen, openedConversationId, - freshConversations, + chatSessionKey, footerNote, onSelectConversation, onNewConversation, @@ -62,7 +62,7 @@ export const AgentChatView = ({
{ searchParams.get(CONVERSATION_QUERY_PARAM) ?? undefined; const [openedConversationId, setOpenedConversationId] = useState(conversationId); - const [freshConversations, setFreshConversations] = useState(0); + const [chatSessionKey, setChatSessionKey] = useState( + () => conversationId ?? 'new', + ); const writeConversationParam = (nextConversationId: string | null) => { const next = new URLSearchParams(searchParams); @@ -108,6 +110,7 @@ const AgentEditorContent = () => { }; const openConversation = (nextConversationId: string) => { setOpenedConversationId(nextConversationId); + setChatSessionKey(nextConversationId); writeConversationParam(nextConversationId); }; const runsOpen = pathname.endsWith(`/${RUNS_TAB}`); @@ -128,7 +131,7 @@ const AgentEditorContent = () => { }; const startNewConversation = () => { setOpenedConversationId(undefined); - setFreshConversations((count) => count + 1); + setChatSessionKey(`new-${Date.now()}`); writeConversationParam(null); }; const { @@ -250,7 +253,7 @@ const AgentEditorContent = () => { agent={agent} conversationsOpen={conversationsOpen} openedConversationId={openedConversationId ?? conversationId} - freshConversations={freshConversations} + chatSessionKey={chatSessionKey} footerNote={buildCapabilityNote(agent)} onSelectConversation={openConversation} onNewConversation={startNewConversation} diff --git a/packages/web/src/app/routes/mcp-authorize/index.tsx b/packages/web/src/app/routes/mcp-authorize/index.tsx index 8f05217170f7..b10c12f7d0c0 100644 --- a/packages/web/src/app/routes/mcp-authorize/index.tsx +++ b/packages/web/src/app/routes/mcp-authorize/index.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { t } from 'i18next'; import { jwtDecode } from 'jwt-decode'; import { CheckCircle, FolderKanban, Lock, Plug, Workflow } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; @@ -21,23 +21,45 @@ import { } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { MultiSelectFilter } from '@/features/automations/components/multi-select-filter'; +import { userHooks } from '@/hooks/user-hooks'; import { api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; +import { FROM_QUERY_PARAM } from '@/lib/navigation-utils'; import { PermissionItem } from './permission-item'; function McpAuthorizePage() { const [searchParams] = useSearchParams(); const authRequestId = searchParams.get('authRequestId'); - const { clientName, isPlatformScoped } = decodeJwtPayload(authRequestId); + const { clientName, isPlatformScoped, expiresAt } = + decodeJwtPayload(authRequestId); const [selectedProjectId, setSelectedProjectId] = useState< string | undefined >(undefined); const [searchValue, setSearchValue] = useState(''); const [selectedTypes, setSelectedTypes] = useState([]); const [authorized, setAuthorized] = useState(false); + const [requestExpired, setRequestExpired] = useState( + expiresAt !== null && expiresAt * 1000 <= Date.now(), + ); + const [requestRejected, setRequestRejected] = useState(false); const debouncedSetSearchValue = useDebouncedCallback(setSearchValue, 300); - const isLoggedIn = authenticationSession.isLoggedIn(); + const markRejectedOnInvalidRequest = (error: unknown) => { + if ( + api.isError(error) && + error.response?.status === 400 && + (error.response.data as { error?: string } | undefined)?.error === + 'invalid_request' + ) { + setRequestRejected(true); + } + }; + const isLoggedIn = + authenticationSession.isLoggedIn() && !authenticationSession.isOnboarding(); + const currentUserId = authenticationSession.getCurrentUserId(); + const signInPath = `/sign-in?${new URLSearchParams({ + [FROM_QUERY_PARAM]: `/mcp-authorize?${searchParams.toString()}`, + }).toString()}`; const projectTypeOptions = [ { value: ProjectType.TEAM, label: t('Team') }, { value: ProjectType.PERSONAL, label: t('Personal') }, @@ -54,6 +76,28 @@ function McpAuthorizePage() { enabled: isLoggedIn && !!authRequestId && !isPlatformScoped, }); + const { data: currentUser } = userHooks.useUserById( + isLoggedIn ? currentUserId : null, + ); + + useEffect(() => { + if (expiresAt === null || requestExpired) { + return; + } + const timer = setTimeout( + () => setRequestExpired(true), + Math.max(expiresAt * 1000 - Date.now(), 0), + ); + return () => clearTimeout(timer); + }, [expiresAt, requestExpired]); + + useEffect(() => { + const projects = projectsPage?.data ?? []; + if (!selectedProjectId && projects.length === 1 && !searchValue) { + setSelectedProjectId(projects[0].id); + } + }, [projectsPage?.data, selectedProjectId, searchValue]); + const approveMutation = useMutation({ mutationFn: (body: { authRequestId: string; projectId?: string }) => api.post<{ redirectUrl: string }>('/v1/mcp-oauth/approve', body), @@ -61,6 +105,16 @@ function McpAuthorizePage() { window.location.href = data.redirectUrl; setAuthorized(true); }, + onError: markRejectedOnInvalidRequest, + }); + + const denyMutation = useMutation({ + mutationFn: (body: { authRequestId: string }) => + api.post<{ redirectUrl: string }>('/v1/mcp-oauth/deny', body), + onSuccess: (data) => { + window.location.href = data.redirectUrl; + }, + onError: markRejectedOnInvalidRequest, }); const { projectsMap, options } = useMemo(() => { @@ -76,11 +130,14 @@ function McpAuthorizePage() { } if (!isLoggedIn) { - const returnUrl = `/mcp-authorize?${searchParams.toString()}`; - const loginParams = new URLSearchParams({ from: returnUrl }); - return ; + return ; } + const switchAccount = () => { + authenticationSession.clearSession(); + window.location.href = signInPath; + }; + const handleAuthorize = () => { if (!isPlatformScoped && !selectedProjectId) return; approveMutation.mutate({ @@ -134,6 +191,19 @@ function McpAuthorizePage() { {clientName}{' '} {t('wants to connect to your Activepieces account')} + {currentUser && ( +

+ {t('Signed in as {email}', { email: currentUser.email })} + · + +

+ )} @@ -190,9 +260,20 @@ function McpAuthorizePage() {
)} - {approveMutation.isError && ( + {(requestExpired || + requestRejected || + approveMutation.isError || + denyMutation.isError) && (
- {t('Authorization failed. Please try again.')} + {requestRejected || + (requestExpired && + !approveMutation.isError && + !denyMutation.isError) + ? t( + 'This request has expired. Go back to {client} and start the connection again.', + { client: clientName }, + ) + : t('Authorization failed. Please try again.')}
)} @@ -201,7 +282,9 @@ function McpAuthorizePage() { type="button" variant="outline" className="flex-1" - onClick={() => window.history.back()} + loading={denyMutation.isPending} + disabled={requestRejected} + onClick={() => denyMutation.mutate({ authRequestId })} > {t('Deny')} @@ -209,7 +292,9 @@ function McpAuthorizePage() { type="button" className="flex-1" loading={approveMutation.isPending} - disabled={!isPlatformScoped && !selectedProjectId} + disabled={ + requestRejected || (!isPlatformScoped && !selectedProjectId) + } onClick={handleAuthorize} > {t('Authorize')} @@ -221,23 +306,35 @@ function McpAuthorizePage() { ); } -function decodeJwtPayload(token: string | null): { - clientName: string; - isPlatformScoped: boolean; -} { +function decodeJwtPayload(token: string | null): AuthRequestSummary { + const fallback: AuthRequestSummary = { + clientName: t('Unknown app'), + isPlatformScoped: false, + expiresAt: null, + }; try { - if (!token) - return { clientName: t('Unknown app'), isPlatformScoped: false }; - const payload = jwtDecode<{ clientName?: string; resource?: string }>( - token, - ); - const clientName = payload.clientName ?? t('Unknown app'); - const isPlatformScoped = - payload.resource?.endsWith('/mcp/platform') ?? false; - return { clientName, isPlatformScoped }; + if (!token) { + return fallback; + } + const payload = jwtDecode<{ + clientName?: string; + resource?: string; + exp?: number; + }>(token); + return { + clientName: payload.clientName ?? t('Unknown app'), + isPlatformScoped: payload.resource?.endsWith('/mcp/platform') ?? false, + expiresAt: payload.exp ?? null, + }; } catch { - return { clientName: t('Unknown app'), isPlatformScoped: false }; + return fallback; } } +type AuthRequestSummary = { + clientName: string; + isPlatformScoped: boolean; + expiresAt: number | null; +}; + export { McpAuthorizePage }; diff --git a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts index cae959f9b881..92fce54d31da 100644 --- a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts +++ b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts @@ -3,7 +3,14 @@ import { TelemetryEventName, } from '@activepieces/shared'; import { t } from 'i18next'; -import { Layers, LucideIcon, Mail, User, Workflow } from 'lucide-react'; +import { + CreditCard, + Layers, + LucideIcon, + Mail, + User, + Workflow, +} from 'lucide-react'; const buildEventLabels = (): Record => ({ [TelemetryEventName.SIGNED_UP]: { @@ -86,6 +93,42 @@ const buildEventLabels = (): Record => ({ group: 'mcp', label: t('MCP tool called'), }, + [TelemetryEventName.ONBOARDING_COMPLETED]: { + group: 'accounts', + label: t('Onboarding completed'), + }, + [TelemetryEventName.INVITE_SENT]: { + group: 'accounts', + label: t('Invitation sent'), + }, + [TelemetryEventName.INVITE_ACCEPTED]: { + group: 'accounts', + label: t('Invitation accepted'), + }, + [TelemetryEventName.CHECKOUT_STARTED]: { + group: 'billing', + label: t('Checkout started'), + }, + [TelemetryEventName.PLAN_CHANGED]: { + group: 'billing', + label: t('Plan changed'), + }, + [TelemetryEventName.PLAN_CANCELLED]: { + group: 'billing', + label: t('Plan cancelled'), + }, + [TelemetryEventName.PLAN_REACTIVATED]: { + group: 'billing', + label: t('Plan reactivated'), + }, + [TelemetryEventName.TRIAL_STARTED]: { + group: 'billing', + label: t('Trial started'), + }, + [TelemetryEventName.SALES_HANDOFF_CLICKED]: { + group: 'billing', + label: t('Contacted sales'), + }, }); const buildGroups = (): TrackedEventGroup[] => { @@ -95,6 +138,7 @@ const buildGroups = (): TrackedEventGroup[] => { { id: 'emailCodes', title: t('Emailed sign-in codes'), icon: Mail }, { id: 'flows', title: t('Flows and the builder'), icon: Workflow }, { id: 'mcp', title: t('MCP'), icon: Layers }, + { id: 'billing', title: t('Billing'), icon: CreditCard }, ]; return definitions .map((definition) => ({ @@ -112,7 +156,12 @@ const buildGroups = (): TrackedEventGroup[] => { export const trackedEventsCatalog = { buildEventLabels, buildGroups }; -export type TrackedEventGroupId = 'accounts' | 'emailCodes' | 'flows' | 'mcp'; +export type TrackedEventGroupId = + | 'accounts' + | 'emailCodes' + | 'flows' + | 'mcp' + | 'billing'; export type TrackedEvent = { group: TrackedEventGroupId; diff --git a/packages/web/src/components/providers/telemetry-provider.tsx b/packages/web/src/components/providers/telemetry-provider.tsx index e7416f675bd4..763581d17605 100644 --- a/packages/web/src/components/providers/telemetry-provider.tsx +++ b/packages/web/src/components/providers/telemetry-provider.tsx @@ -1,7 +1,9 @@ -import { isNil } from '@activepieces/core-utils'; +import { isNil, tryCatchSync } from '@activepieces/core-utils'; import { ApEdition, + ApEnvironment, ApFlagId, + DeploymentKind, isCloudOnlyTelemetryEvent, pickTelemetryPii, TelemetryEvent, @@ -30,13 +32,14 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { const { data: flagCurrentVersion } = flagsHooks.useFlag( ApFlagId.CURRENT_VERSION, ); - const { data: flagEnvironment } = flagsHooks.useFlag( + const { data: flagEnvironment } = flagsHooks.useFlag( ApFlagId.ENVIRONMENT, ); const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); const { embedState } = useEmbedding(); const isCloud = edition === ApEdition.CLOUD; + const isDevEnvironment = flagEnvironment === ApEnvironment.DEVELOPMENT; const isPreLoginCloudFunnel = isNil(currentUser) && isCloud && @@ -44,9 +47,14 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { const isSignedInWithAnalyticsOn = !isNil(currentUser) && (isCloud || configuration?.isProductTelemetryEnabled === true); + const devTelemetryAllowed = + isPosthogDevOptIn() || (!isRunningCloudInDevMode && !isDevEnvironment); const telemetryEnabled = - (isPreLoginCloudFunnel || isSignedInWithAnalyticsOn) && - !isRunningCloudInDevMode; + (isPreLoginCloudFunnel || isSignedInWithAnalyticsOn) && devTelemetryAllowed; + const deployment = resolveDeploymentKind({ + edition, + environment: flagEnvironment, + }); const posthogInitialized = useRef(false); @@ -96,12 +104,12 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { }); // Tag events so the shared project separates product from marketing traffic. - posthog.register({ source_site: 'product' }); + posthog.register({ source_site: 'product', deployment }); if (isCloud && isInRecordingSample(posthog.get_distinct_id())) { posthog.startSessionRecording(); } - }, [telemetryEnabled, embedState.isEmbedded, edition, isCloud]); + }, [telemetryEnabled, embedState.isEmbedded, edition, isCloud, deployment]); useEffect(() => { if (!posthogInitialized.current) { @@ -111,6 +119,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { activepiecesEdition: edition ?? ApEdition.COMMUNITY, activepiecesVersion: flagCurrentVersion ?? UNKNOWN_FLAG_VALUE, activepiecesEnvironment: flagEnvironment ?? UNKNOWN_FLAG_VALUE, + deployment, }); }, [ telemetryEnabled, @@ -118,6 +127,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { embedState.isEmbedded, flagCurrentVersion, flagEnvironment, + deployment, ]); useEffect(() => { @@ -153,6 +163,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { }), activepiecesVersion: currentVersion, activepiecesEnvironment: environment, + deployment, }); if (currentUser.platformId) { @@ -181,6 +192,8 @@ const UNKNOWN_FLAG_VALUE = '0.0.0'; const RECORDING_SAMPLE_RATE = 0.1; +const POSTHOG_DEV_OPT_IN_KEY = 'ap_posthog_dev'; + function isInRecordingSample(distinctId: string): boolean { let hash = 5381; for (let i = 0; i < distinctId.length; i++) { @@ -189,6 +202,29 @@ function isInRecordingSample(distinctId: string): boolean { return (hash >>> 0) / 0xffffffff < RECORDING_SAMPLE_RATE; } +function isPosthogDevOptIn(): boolean { + const { data } = tryCatchSync( + () => localStorage.getItem(POSTHOG_DEV_OPT_IN_KEY) === '1', + ); + return data === true; +} + +function resolveDeploymentKind({ + edition, + environment, +}: { + edition: ApEdition | null | undefined; + environment: ApEnvironment | null | undefined; +}): DeploymentKind { + if (edition === ApEdition.CLOUD) { + return DeploymentKind.CLOUD; + } + if (environment === ApEnvironment.DEVELOPMENT) { + return DeploymentKind.DEV; + } + return DeploymentKind.SELF_HOSTED; +} + interface TelemetryContextType { capture: (event: TelemetryEvent) => void; } diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 5d0fa5aecec6..2d22e89433e6 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -13,7 +13,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -27,7 +27,11 @@ import { acquisitionUtils } from '@/lib/acquisition-utils'; import { HttpError, api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; import { formatUtils } from '@/lib/format-utils'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; +import { + FROM_QUERY_PARAM, + pendingRedirect, + useRedirectAfterLogin, +} from '@/lib/navigation-utils'; import { CheckEmailNote } from './check-email-note'; @@ -53,6 +57,7 @@ const SignInForm = ({ onForgotPassword }: SignInFormProps) => { const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); const redirectAfterLogin = useRedirectAfterLogin(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { capture } = useTelemetry(); @@ -100,6 +105,7 @@ const SignInForm = ({ onForgotPassword }: SignInFormProps) => { break; } case ErrorCode.EMAIL_IS_NOT_VERIFIED: { + pendingRedirect.remember(searchParams.get(FROM_QUERY_PARAM)); setShowCheckYourEmailNote(true); break; } diff --git a/packages/web/src/features/authentication/components/sign-up-form.tsx b/packages/web/src/features/authentication/components/sign-up-form.tsx index 4a3150e390ca..177edf4078dc 100644 --- a/packages/web/src/features/authentication/components/sign-up-form.tsx +++ b/packages/web/src/features/authentication/components/sign-up-form.tsx @@ -40,7 +40,11 @@ import { acquisitionUtils } from '@/lib/acquisition-utils'; import { api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; import { formatUtils } from '@/lib/format-utils'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; +import { + FROM_QUERY_PARAM, + pendingRedirect, + useRedirectAfterLogin, +} from '@/lib/navigation-utils'; import { authMutations } from '../hooks/auth-hooks'; import { captchaUtils } from '../utils/captcha-utils'; @@ -98,6 +102,10 @@ const SignUpForm = ({ const redirectAfterLogin = useRedirectAfterLogin(); const navigate = useNavigate(); const { capture } = useTelemetry(); + const askToCheckEmail = () => { + pendingRedirect.remember(searchParams.get(FROM_QUERY_PARAM)); + setShowCheckYourEmailNote(true); + }; const { mutate, isPending } = authMutations.useSignUp({ onSuccess: (data) => { @@ -110,7 +118,7 @@ const SignUpForm = ({ } redirectAfterLogin(); } else { - setShowCheckYourEmailNote(true); + askToCheckEmail(); } }, onError: (error) => { @@ -139,7 +147,7 @@ const SignUpForm = ({ } switch (errorCode) { case ErrorCode.EMAIL_IS_NOT_VERIFIED: { - setShowCheckYourEmailNote(true); + askToCheckEmail(); break; } case ErrorCode.INVITATION_ONLY_SIGN_UP: { diff --git a/packages/web/src/features/authentication/components/verify-email.tsx b/packages/web/src/features/authentication/components/verify-email.tsx index 36fd77ca88c3..5e67d3dd1819 100644 --- a/packages/web/src/features/authentication/components/verify-email.tsx +++ b/packages/web/src/features/authentication/components/verify-email.tsx @@ -12,6 +12,7 @@ import { Card } from '@/components/ui/card'; import { internalErrorToast } from '@/components/ui/sonner'; import { usePartnerStack } from '@/hooks/use-partner-stack'; import { api } from '@/lib/api'; +import { pendingRedirect } from '@/lib/navigation-utils'; import { authMutations } from '../hooks/auth-hooks'; @@ -32,7 +33,7 @@ const VerifyEmail = () => { payload: {}, }); reportSignup(email, firstName); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); }, onError: (error) => { if ( @@ -40,11 +41,11 @@ const VerifyEmail = () => { error.response?.status === HttpStatusCode.Gone ) { setIsExpired(true); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); } else { console.error(error); internalErrorToast(); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); } }, }); diff --git a/packages/web/src/features/billing/components/plan-selector-utils.ts b/packages/web/src/features/billing/components/plan-selector-utils.ts index b78022472fcd..500147584a4a 100644 --- a/packages/web/src/features/billing/components/plan-selector-utils.ts +++ b/packages/web/src/features/billing/components/plan-selector-utils.ts @@ -4,7 +4,7 @@ import { t } from 'i18next'; const FREE_PLAN_ID = 'free'; const ANNUAL_INTERVAL = 'year'; -const SALES_URL = 'https://activepieces.com/sales'; +const SALES_URL = 'https://www.activepieces.com/sales?ap_cta=app_plan_selector'; function stripPlanInterval(name: string): string { return name.replace(/\s*\((annual|monthly|yearly)\)\s*/i, '').trim(); diff --git a/packages/web/src/features/billing/components/plan-selector.tsx b/packages/web/src/features/billing/components/plan-selector.tsx index 5dd6b6bcdc99..a41bd6e1771d 100644 --- a/packages/web/src/features/billing/components/plan-selector.tsx +++ b/packages/web/src/features/billing/components/plan-selector.tsx @@ -1,9 +1,10 @@ import { isNil } from '@activepieces/core-utils'; -import { PurchasablePlan } from '@activepieces/shared'; +import { PurchasablePlan, TelemetryEventName } from '@activepieces/shared'; import { t } from 'i18next'; import { Check, Info } from 'lucide-react'; import { useState } from 'react'; +import { useTelemetry } from '@/components/providers/telemetry-provider'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -350,6 +351,7 @@ function PlanCta({ onKeepPlan, onDowngrade, }: PlanCtaProps) { + const { capture } = useTelemetry(); if (isEnterprise) { return (