d90f06ba5e
## 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.
124 lines
3.8 KiB
TypeScript
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);
|
|
}
|
|
}
|