Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/plain-graphql-migration.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/webapp/app/routes/api.v1.plain.customer-cards.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
4 changes: 1 addition & 3 deletions apps/webapp/app/routes/resources.feedback.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.`,
Expand Down
135 changes: 98 additions & 37 deletions apps/webapp/app/utils/plain.server.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -9,9 +9,20 @@ type Input = {
title: string;
components: ReturnType<typeof uiComponent.text>[];
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;
}
Expand All @@ -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;
Comment thread
isshaddad marked this conversation as resolved.

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);
}
Comment thread
isshaddad marked this conversation as resolved.
}
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/setBranchesAddOn.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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})`,
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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})`,
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/setSchedulesAddOn.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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})`,
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/setSeatsAddOn.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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})`,
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
Expand Down
40 changes: 25 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading