diff --git a/.server-changes/plain-graphql-migration.md b/.server-changes/plain-graphql-migration.md new file mode 100644 index 0000000000..81468a9144 --- /dev/null +++ b/.server-changes/plain-graphql-migration.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +In-app feedback and add-on/quota requests are now recorded with more account context for the support team. diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index ad869c7a4f..5e09fb0854 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -1,6 +1,6 @@ import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { timingSafeEqual } from "crypto"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; diff --git a/apps/webapp/app/routes/resources.feedback.ts b/apps/webapp/app/routes/resources.feedback.ts index bdb16ab799..4ace262f0c 100644 --- a/apps/webapp/app/routes/resources.feedback.ts +++ b/apps/webapp/app/routes/resources.feedback.ts @@ -1,13 +1,11 @@ import { parseWithZod } from "@conform-to/zod"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { type PlainClient, uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; import { z } from "zod"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; import { sendToPlain } from "~/utils/plain.server"; -let _client: PlainClient | undefined; - export const feedbackTypes = { bug: { label: "Bug report", diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 2c107c35b1..b72033098b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -1,7 +1,7 @@ import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Form, useLocation, useNavigation } from "@remix-run/react"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; import { type AddOnPricing, type FreePlanDefinition, @@ -95,6 +95,8 @@ export const action = dashboardAction( email: user.email, name: user.name ?? "", title: "Plan cancelation feedback", + organizationId: organization.id, + organizationName: organization.title, components: [ uiComponent.text({ text: `${user.name} (${user.email}) just canceled their plan.`, diff --git a/apps/webapp/app/utils/plain.server.ts b/apps/webapp/app/utils/plain.server.ts index c17b2f3f00..2dde84128e 100644 --- a/apps/webapp/app/utils/plain.server.ts +++ b/apps/webapp/app/utils/plain.server.ts @@ -1,5 +1,5 @@ -import type { uiComponent } from "@team-plain/typescript-sdk"; -import { PlainClient } from "@team-plain/typescript-sdk"; +import { PlainClient } from "@team-plain/graphql"; +import type { uiComponent } from "@team-plain/ui-components"; import { env } from "~/env.server"; type Input = { @@ -9,9 +9,20 @@ type Input = { title: string; components: ReturnType[]; labelTypeIds?: string[]; + organizationId?: string; + organizationName?: string; }; -export async function sendToPlain({ userId, email, name, title, components, labelTypeIds }: Input) { +export async function sendToPlain({ + userId, + email, + name, + title, + components, + labelTypeIds, + organizationId, + organizationName, +}: Input) { if (!env.PLAIN_API_KEY) { return; } @@ -20,43 +31,93 @@ export async function sendToPlain({ userId, email, name, title, components, labe apiKey: env.PLAIN_API_KEY, }); - const upsertCustomerRes = await client.upsertCustomer({ - identifier: { - emailAddress: email, - }, - onCreate: { - externalId: userId, - fullName: name, - email: { - email: email, - isVerified: true, + // Best-effort support side-effect. Only transport/auth errors throw (caught below); business + // and validation failures come back in each mutation's `result.error`, so we check those inline. + try { + const upsertCustomerRes = await client.mutation.upsertCustomer({ + input: { + identifier: { + emailAddress: email, + }, + onCreate: { + externalId: userId, + fullName: name, + email: { + email: email, + isVerified: true, + }, + }, + onUpdate: { + externalId: { value: userId }, + fullName: { value: name }, + email: { + email: email, + isVerified: true, + }, + }, }, - }, - onUpdate: { - externalId: { value: userId }, - fullName: { value: name }, - email: { - email: email, - isVerified: true, - }, - }, - }); + }); - if (upsertCustomerRes.error) { - console.error("Failed to upsert customer in Plain", upsertCustomerRes.error); - return; - } + if (upsertCustomerRes.error || !upsertCustomerRes.customer?.id) { + console.error("Failed to upsert customer in Plain", upsertCustomerRes.error); + return; + } + const customerId = upsertCustomerRes.customer.id; - const createThreadRes = await client.createThread({ - customerIdentifier: { - customerId: upsertCustomerRes.data.customer.id, - }, - title: title, - components: components, - labelTypeIds, - }); + // Attribute the thread to the org so support data can be rolled up per org: the tenant is + // keyed by externalId = org_id. Isolated in its own try/catch, and the thread's + // tenantIdentifier is gated on success — so a tenant failure (e.g. an API key without + // tenant scope) downgrades to "no attribution" instead of dropping the thread. The + // customer's own externalId (User.id, used by the customer cards + impersonation link) is + // left untouched. + let tenantLinked = false; + if (organizationId) { + try { + const tenantRes = await client.mutation.upsertTenant({ + input: { + identifier: { externalId: organizationId }, + externalId: organizationId, + name: organizationName ?? organizationId, + }, + }); + // Only link + attribute if the tenant genuinely upserted — a mutation error comes back in + // `.error` (not thrown), and stamping the thread with a tenant that wasn't created would + // make createThread itself fail. + const membershipRes = tenantRes.error + ? undefined + : await client.mutation.addCustomerToTenants({ + input: { + customerIdentifier: { customerId }, + tenantIdentifiers: [{ externalId: organizationId }], + }, + }); + if (tenantRes.error) { + console.error("Failed to upsert Plain tenant", tenantRes.error); + } else if (membershipRes?.error) { + console.error("Failed to link Plain customer to tenant", membershipRes.error); + } else { + tenantLinked = true; + } + } catch (error) { + console.error("Failed to link Plain customer to org tenant", error); + } + } - if (createThreadRes.error) { - console.error("Failed to create thread in Plain", createThreadRes.error); + const threadRes = await client.mutation.createThread({ + input: { + customerIdentifier: { + customerId, + }, + title: title, + components: components, + labelTypeIds, + tenantIdentifier: tenantLinked ? { externalId: organizationId } : undefined, + }, + }); + if (threadRes.error) { + console.error("Failed to create Plain thread", threadRes.error); + } + } catch (error) { + console.error("Failed to send to Plain", error); } } diff --git a/apps/webapp/app/v3/services/setBranchesAddOn.server.ts b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts index 55f4f5817d..b14aebfbe9 100644 --- a/apps/webapp/app/v3/services/setBranchesAddOn.server.ts +++ b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts @@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { setBranchesAddOn } from "~/services/platform.v3.server"; import assertNever from "assert-never"; import { sendToPlain } from "~/utils/plain.server"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; type Input = { userId: string; @@ -74,6 +74,8 @@ export class SetBranchesAddOnService extends BaseService { email: user.email, name: user.name ?? user.displayName ?? user.email, title: `Preview branches quota request: ${amount}`, + organizationId, + organizationName: organization?.title, components: [ uiComponent.text({ text: `Org: ${organization?.title} (${organizationId})`, diff --git a/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts index 6daf970bbd..da1f61b37f 100644 --- a/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts +++ b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts @@ -4,7 +4,7 @@ import { tryCatch } from "@trigger.dev/core"; import { setConcurrencyAddOn } from "~/services/platform.v3.server"; import assertNever from "assert-never"; import { sendToPlain } from "~/utils/plain.server"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; type Input = { userId: string; @@ -106,6 +106,8 @@ export class SetConcurrencyAddOnService extends BaseService { email: user.email, name: user.name ?? user.displayName ?? user.email, title: `Concurrency quota request: ${totalExtraConcurrency}`, + organizationId, + organizationName: organization?.title, components: [ uiComponent.text({ text: `Org: ${organization?.title} (${organizationId})`, diff --git a/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts b/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts index a96a600076..42b3d12036 100644 --- a/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts +++ b/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts @@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { setSchedulesAddOn } from "~/services/platform.v3.server"; import assertNever from "assert-never"; import { sendToPlain } from "~/utils/plain.server"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; type Input = { userId: string; @@ -74,6 +74,8 @@ export class SetSchedulesAddOnService extends BaseService { email: user.email, name: user.name ?? user.displayName ?? user.email, title: `Schedules quota request: ${amount}`, + organizationId, + organizationName: organization?.title, components: [ uiComponent.text({ text: `Org: ${organization?.title} (${organizationId})`, diff --git a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts index b9159c2ee7..a761e7f976 100644 --- a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts +++ b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts @@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { setSeatsAddOn } from "~/services/platform.v3.server"; import assertNever from "assert-never"; import { sendToPlain } from "~/utils/plain.server"; -import { uiComponent } from "@team-plain/typescript-sdk"; +import { uiComponent } from "@team-plain/ui-components"; type Input = { userId: string; @@ -74,6 +74,8 @@ export class SetSeatsAddOnService extends BaseService { email: user.email, name: user.name ?? user.displayName ?? user.email, title: `Seats quota request: ${amount}`, + organizationId, + organizationName: organization?.title, components: [ uiComponent.text({ text: `Org: ${organization?.title} (${organizationId})`, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 678eeb7603..c51882d003 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -112,7 +112,8 @@ "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.0.4", - "@team-plain/typescript-sdk": "^3.5.0", + "@team-plain/graphql": "^1.3.0", + "@team-plain/ui-components": "^5.0.0", "@trigger.dev/companyicons": "^1.5.35", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09dc718a6..787406e25e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,9 +462,12 @@ importers: '@tanstack/react-virtual': specifier: ^3.0.4 version: 3.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@team-plain/typescript-sdk': - specifier: ^3.5.0 - version: 3.5.0 + '@team-plain/graphql': + specifier: ^1.3.0 + version: 1.3.0 + '@team-plain/ui-components': + specifier: ^5.0.0 + version: 5.0.0(@team-plain/graphql@1.3.0) '@trigger.dev/companyicons': specifier: ^1.5.35 version: 1.5.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -7659,9 +7662,13 @@ packages: '@tanstack/virtual-core@3.0.0': resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} - '@team-plain/typescript-sdk@3.5.0': - resolution: {integrity: sha512-9kweiSlYAN31VI7yzILGxdlZqsGJ+FmCEfXyEZ/0/i3r6vOwq45FDqtjadnQJVtFm+rf/8vCFRN+wEYMIEv6Aw==} - deprecated: This package is now deprecated. Please use @team-plain/graphql, @team-plain/webhooks and @team-plain/ui-components (https://github.com/team-plain/sdk) + '@team-plain/graphql@1.3.0': + resolution: {integrity: sha512-e+AZ9sIn4uliAyg7efcX6F0L4qOuwqL+6a+f5qe7Bfp7yXB5DU3vwECxHWN9YCD30MXnJ3oeS8Co1T5C5r0DUQ==} + + '@team-plain/ui-components@5.0.0': + resolution: {integrity: sha512-7/k8GggIGhdzkrZD3LAYWQZgJZi7VB3bQkpGbL67VrfoQGLYRumCMJ8jEvfLgX8n/tJ6Xn03u5etwLBBeb+Kqw==} + peerDependencies: + '@team-plain/graphql': 1.3.0 '@testcontainers/postgresql@11.14.0': resolution: {integrity: sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w==} @@ -10742,8 +10749,8 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - graphql@16.6.0: - resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gunzip-maybe@1.4.2: @@ -18569,9 +18576,9 @@ snapshots: '@google-cloud/precise-date@4.0.0': {} - '@graphql-typed-document-node/core@3.2.0(graphql@16.6.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: - graphql: 16.6.0 + graphql: 16.14.2 '@grpc/grpc-js@1.12.6': dependencies: @@ -22303,11 +22310,14 @@ snapshots: '@tanstack/virtual-core@3.0.0': {} - '@team-plain/typescript-sdk@3.5.0': + '@team-plain/graphql@1.3.0': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) - graphql: 16.6.0 - zod: 3.25.76 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + graphql: 16.14.2 + + '@team-plain/ui-components@5.0.0(@team-plain/graphql@1.3.0)': + dependencies: + '@team-plain/graphql': 1.3.0 '@testcontainers/postgresql@11.14.0': dependencies: @@ -25974,7 +25984,7 @@ snapshots: grapheme-splitter@1.0.4: {} - graphql@16.6.0: {} + graphql@16.14.2: {} gunzip-maybe@1.4.2: dependencies: