Files
Iss d90f06ba5e 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.
2026-07-30 14:55:20 -04:00

124 lines
3.8 KiB
TypeScript

import { PlainClient } from "@team-plain/graphql";
import type { uiComponent } from "@team-plain/ui-components";
import { env } from "~/env.server";
type Input = {
userId: string;
email: string;
name: string;
title: string;
components: ReturnType<typeof uiComponent.text>[];
labelTypeIds?: string[];
organizationId?: string;
organizationName?: string;
};
export async function sendToPlain({
userId,
email,
name,
title,
components,
labelTypeIds,
organizationId,
organizationName,
}: Input) {
if (!env.PLAIN_API_KEY) {
return;
}
const client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
// 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,
},
},
},
});
if (upsertCustomerRes.error || !upsertCustomerRes.customer?.id) {
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
return;
}
const customerId = upsertCustomerRes.customer.id;
// 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);
}
}
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);
}
}