Skip to content

Commit d90f06b

Browse files
authored
feat(webapp): migrate Plain to @team-plain/graphql + attribute support threads to org tenant (#4368)
## What Two changes, shipped together: 1. **SDK migration (TRI-12460).** `@team-plain/typescript-sdk` is deprecated. Move the webapp to its successors — `@team-plain/graphql` (client) and `@team-plain/ui-components` (`uiComponent` builder). Behaviour-preserving: the `PlainClient` customer upsert + thread creation move to the new `client.mutation.*({ input })` shape; the client now throws on failure, so `sendToPlain` wraps its calls and logs, staying best-effort. 2. **Org tenant attribution (TRI-12461).** When org context is available, `sendToPlain` now upserts a Plain tenant keyed by `externalId = org_id`, links the customer to it, and stamps the created thread with that tenant — so support threads become attributable to a Trigger.dev org. Wired into the four add-on quota requests and the plan-cancellation feedback (which already have org context). The tenant steps are isolated in their own try/catch and the thread's `tenantIdentifier` is gated on their success, so a tenant failure never blocks thread creation. ## Not affected - `customer.externalId` stays `User.id` — the customer cards + impersonation link are unchanged. - No ticket content leaves Plain. - Callers without a single org (e.g. the feedback widget) are unchanged — the org params are optional. ## Deploy prerequisite The webapp's Plain API key needs three **new** scopes for attribution to work (it already has `customer:create`, `customer:edit`, `thread:create`): - [x] `tenant:create` - [x] `tenant:edit` - [x] `customerTenantMembership:create` Until granted, nothing breaks — `sendToPlain` logs the forbidden error and creates the thread without attribution. ## Testing - `pnpm typecheck --filter webapp` passes; oxfmt + oxlint clean. - Ran the real `sendToPlain` end-to-end via a throwaway vitest harness against live Plain — confirmed the code path executes; the live write is gated only by the key scopes above.
1 parent 86b948b commit d90f06b

11 files changed

Lines changed: 148 additions & 62 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
In-app feedback and add-on/quota requests are now recorded with more account context for the support team.

apps/webapp/app/routes/api.v1.plain.customer-cards.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { timingSafeEqual } from "crypto";
3-
import { uiComponent } from "@team-plain/typescript-sdk";
3+
import { uiComponent } from "@team-plain/ui-components";
44
import { z } from "zod";
55
import { prisma } from "~/db.server";
66
import { env } from "~/env.server";

apps/webapp/app/routes/resources.feedback.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { parseWithZod } from "@conform-to/zod";
22
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
3-
import { type PlainClient, uiComponent } from "@team-plain/typescript-sdk";
3+
import { uiComponent } from "@team-plain/ui-components";
44
import { z } from "zod";
55
import { redirectWithSuccessMessage } from "~/models/message.server";
66
import { requireUser } from "~/services/session.server";
77
import { sendToPlain } from "~/utils/plain.server";
88

9-
let _client: PlainClient | undefined;
10-
119
export const feedbackTypes = {
1210
bug: {
1311
label: "Bug report",

apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
22
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
33
import { Form, useLocation, useNavigation } from "@remix-run/react";
4-
import { uiComponent } from "@team-plain/typescript-sdk";
4+
import { uiComponent } from "@team-plain/ui-components";
55
import {
66
type AddOnPricing,
77
type FreePlanDefinition,
@@ -95,6 +95,8 @@ export const action = dashboardAction(
9595
email: user.email,
9696
name: user.name ?? "",
9797
title: "Plan cancelation feedback",
98+
organizationId: organization.id,
99+
organizationName: organization.title,
98100
components: [
99101
uiComponent.text({
100102
text: `${user.name} (${user.email}) just canceled their plan.`,
Lines changed: 98 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { uiComponent } from "@team-plain/typescript-sdk";
2-
import { PlainClient } from "@team-plain/typescript-sdk";
1+
import { PlainClient } from "@team-plain/graphql";
2+
import type { uiComponent } from "@team-plain/ui-components";
33
import { env } from "~/env.server";
44

55
type Input = {
@@ -9,9 +9,20 @@ type Input = {
99
title: string;
1010
components: ReturnType<typeof uiComponent.text>[];
1111
labelTypeIds?: string[];
12+
organizationId?: string;
13+
organizationName?: string;
1214
};
1315

14-
export async function sendToPlain({ userId, email, name, title, components, labelTypeIds }: Input) {
16+
export async function sendToPlain({
17+
userId,
18+
email,
19+
name,
20+
title,
21+
components,
22+
labelTypeIds,
23+
organizationId,
24+
organizationName,
25+
}: Input) {
1526
if (!env.PLAIN_API_KEY) {
1627
return;
1728
}
@@ -20,43 +31,93 @@ export async function sendToPlain({ userId, email, name, title, components, labe
2031
apiKey: env.PLAIN_API_KEY,
2132
});
2233

23-
const upsertCustomerRes = await client.upsertCustomer({
24-
identifier: {
25-
emailAddress: email,
26-
},
27-
onCreate: {
28-
externalId: userId,
29-
fullName: name,
30-
email: {
31-
email: email,
32-
isVerified: true,
34+
// Best-effort support side-effect. Only transport/auth errors throw (caught below); business
35+
// and validation failures come back in each mutation's `result.error`, so we check those inline.
36+
try {
37+
const upsertCustomerRes = await client.mutation.upsertCustomer({
38+
input: {
39+
identifier: {
40+
emailAddress: email,
41+
},
42+
onCreate: {
43+
externalId: userId,
44+
fullName: name,
45+
email: {
46+
email: email,
47+
isVerified: true,
48+
},
49+
},
50+
onUpdate: {
51+
externalId: { value: userId },
52+
fullName: { value: name },
53+
email: {
54+
email: email,
55+
isVerified: true,
56+
},
57+
},
3358
},
34-
},
35-
onUpdate: {
36-
externalId: { value: userId },
37-
fullName: { value: name },
38-
email: {
39-
email: email,
40-
isVerified: true,
41-
},
42-
},
43-
});
59+
});
4460

45-
if (upsertCustomerRes.error) {
46-
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
47-
return;
48-
}
61+
if (upsertCustomerRes.error || !upsertCustomerRes.customer?.id) {
62+
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
63+
return;
64+
}
65+
const customerId = upsertCustomerRes.customer.id;
4966

50-
const createThreadRes = await client.createThread({
51-
customerIdentifier: {
52-
customerId: upsertCustomerRes.data.customer.id,
53-
},
54-
title: title,
55-
components: components,
56-
labelTypeIds,
57-
});
67+
// Attribute the thread to the org so support data can be rolled up per org: the tenant is
68+
// keyed by externalId = org_id. Isolated in its own try/catch, and the thread's
69+
// tenantIdentifier is gated on success — so a tenant failure (e.g. an API key without
70+
// tenant scope) downgrades to "no attribution" instead of dropping the thread. The
71+
// customer's own externalId (User.id, used by the customer cards + impersonation link) is
72+
// left untouched.
73+
let tenantLinked = false;
74+
if (organizationId) {
75+
try {
76+
const tenantRes = await client.mutation.upsertTenant({
77+
input: {
78+
identifier: { externalId: organizationId },
79+
externalId: organizationId,
80+
name: organizationName ?? organizationId,
81+
},
82+
});
83+
// Only link + attribute if the tenant genuinely upserted — a mutation error comes back in
84+
// `.error` (not thrown), and stamping the thread with a tenant that wasn't created would
85+
// make createThread itself fail.
86+
const membershipRes = tenantRes.error
87+
? undefined
88+
: await client.mutation.addCustomerToTenants({
89+
input: {
90+
customerIdentifier: { customerId },
91+
tenantIdentifiers: [{ externalId: organizationId }],
92+
},
93+
});
94+
if (tenantRes.error) {
95+
console.error("Failed to upsert Plain tenant", tenantRes.error);
96+
} else if (membershipRes?.error) {
97+
console.error("Failed to link Plain customer to tenant", membershipRes.error);
98+
} else {
99+
tenantLinked = true;
100+
}
101+
} catch (error) {
102+
console.error("Failed to link Plain customer to org tenant", error);
103+
}
104+
}
58105

59-
if (createThreadRes.error) {
60-
console.error("Failed to create thread in Plain", createThreadRes.error);
106+
const threadRes = await client.mutation.createThread({
107+
input: {
108+
customerIdentifier: {
109+
customerId,
110+
},
111+
title: title,
112+
components: components,
113+
labelTypeIds,
114+
tenantIdentifier: tenantLinked ? { externalId: organizationId } : undefined,
115+
},
116+
});
117+
if (threadRes.error) {
118+
console.error("Failed to create Plain thread", threadRes.error);
119+
}
120+
} catch (error) {
121+
console.error("Failed to send to Plain", error);
61122
}
62123
}

apps/webapp/app/v3/services/setBranchesAddOn.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils";
33
import { setBranchesAddOn } from "~/services/platform.v3.server";
44
import assertNever from "assert-never";
55
import { sendToPlain } from "~/utils/plain.server";
6-
import { uiComponent } from "@team-plain/typescript-sdk";
6+
import { uiComponent } from "@team-plain/ui-components";
77

88
type Input = {
99
userId: string;
@@ -74,6 +74,8 @@ export class SetBranchesAddOnService extends BaseService {
7474
email: user.email,
7575
name: user.name ?? user.displayName ?? user.email,
7676
title: `Preview branches quota request: ${amount}`,
77+
organizationId,
78+
organizationName: organization?.title,
7779
components: [
7880
uiComponent.text({
7981
text: `Org: ${organization?.title} (${organizationId})`,

apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { tryCatch } from "@trigger.dev/core";
44
import { setConcurrencyAddOn } from "~/services/platform.v3.server";
55
import assertNever from "assert-never";
66
import { sendToPlain } from "~/utils/plain.server";
7-
import { uiComponent } from "@team-plain/typescript-sdk";
7+
import { uiComponent } from "@team-plain/ui-components";
88

99
type Input = {
1010
userId: string;
@@ -106,6 +106,8 @@ export class SetConcurrencyAddOnService extends BaseService {
106106
email: user.email,
107107
name: user.name ?? user.displayName ?? user.email,
108108
title: `Concurrency quota request: ${totalExtraConcurrency}`,
109+
organizationId,
110+
organizationName: organization?.title,
109111
components: [
110112
uiComponent.text({
111113
text: `Org: ${organization?.title} (${organizationId})`,

apps/webapp/app/v3/services/setSchedulesAddOn.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils";
33
import { setSchedulesAddOn } from "~/services/platform.v3.server";
44
import assertNever from "assert-never";
55
import { sendToPlain } from "~/utils/plain.server";
6-
import { uiComponent } from "@team-plain/typescript-sdk";
6+
import { uiComponent } from "@team-plain/ui-components";
77

88
type Input = {
99
userId: string;
@@ -74,6 +74,8 @@ export class SetSchedulesAddOnService extends BaseService {
7474
email: user.email,
7575
name: user.name ?? user.displayName ?? user.email,
7676
title: `Schedules quota request: ${amount}`,
77+
organizationId,
78+
organizationName: organization?.title,
7779
components: [
7880
uiComponent.text({
7981
text: `Org: ${organization?.title} (${organizationId})`,

apps/webapp/app/v3/services/setSeatsAddOn.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils";
33
import { setSeatsAddOn } from "~/services/platform.v3.server";
44
import assertNever from "assert-never";
55
import { sendToPlain } from "~/utils/plain.server";
6-
import { uiComponent } from "@team-plain/typescript-sdk";
6+
import { uiComponent } from "@team-plain/ui-components";
77

88
type Input = {
99
userId: string;
@@ -74,6 +74,8 @@ export class SetSeatsAddOnService extends BaseService {
7474
email: user.email,
7575
name: user.name ?? user.displayName ?? user.email,
7676
title: `Seats quota request: ${amount}`,
77+
organizationId,
78+
organizationName: organization?.title,
7779
components: [
7880
uiComponent.text({
7981
text: `Org: ${organization?.title} (${organizationId})`,

apps/webapp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"@tanstack/match-sorter-utils": "^8.19.4",
114114
"@tanstack/react-table": "^8.21.3",
115115
"@tanstack/react-virtual": "^3.0.4",
116-
"@team-plain/typescript-sdk": "^3.5.0",
116+
"@team-plain/graphql": "^1.3.0",
117+
"@team-plain/ui-components": "^5.0.0",
117118
"@trigger.dev/companyicons": "^1.5.35",
118119
"@trigger.dev/core": "workspace:*",
119120
"@trigger.dev/database": "workspace:*",

0 commit comments

Comments
 (0)