Skip to content
Merged
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
100 changes: 100 additions & 0 deletions .changeset/little-jars-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
"@team-plain/graphql": major
"@team-plain/ui-components": major
---

Regenerate against the current API schema.

**No longer in the generated result types**

- Field 'Customer.tenantMemberships' is deprecated
- Field 'Customer.company' is deprecated
- Field 'ThreadsDisplayOptions.hasCompany' is deprecated
- Field 'ThreadsDisplayOptions.hasTenants' is deprecated
- Field 'ThreadsDisplayOptions.hasTenantFieldExternalIds' is deprecated
- Field 'SavedThreadsViewNestedFilter.companies' is deprecated
- Field 'SavedThreadsViewNestedFilter.tenants' is deprecated
- Field 'SavedThreadsViewNestedFilter.tenantFields' is deprecated
- Field 'SavedThreadsViewFilter.companies' is deprecated
- Field 'SavedThreadsViewFilter.tenants' is deprecated
- Field 'SavedThreadsViewFilter.tenantFields' is deprecated
- Field 'Task.company' is deprecated
- Field 'Task.tenant' is deprecated
- Field 'BroadcastAudienceFilter.tenantIds' is deprecated
- Field 'BroadcastAudienceFilter.tenantFields' is deprecated
- Field 'ThreadLinkGroup.companyMetrics' is deprecated
- Field 'ThreadLinkGroup.tenantMetrics' is deprecated
- Field 'ThreadChannelAssociation.companyId' is deprecated
- Field 'ThreadChannelAssociation.tenantId' is deprecated
- Field 'ThreadChannelAssociation.tenantMembershipPolicy' is deprecated
- Field 'SlackThreadChannelAssociation.companyId' is deprecated
- Field 'SlackThreadChannelAssociation.tenantId' is deprecated
- Field 'SlackThreadChannelAssociation.tenantMembershipPolicy' is deprecated
- Field 'Thread.tenant' is deprecated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something is wrong with how this changelog is generated as this has not been removed, just marked as deprecated which is quite different.

I'm going to be taking this over from @mpsq and can then fix this script plus the pending release.

If you want to have a look at this in the meantime, please be my guest!

- Field 'HelpCenterAccessSettings.tenantIds' is deprecated
- Field 'HelpCenterAccessSettings.companyIds' is deprecated
- Field 'HelpCenterPortalSettingsThreadVisibility.customerCompany' is deprecated
- Field 'HelpCenterPortalSettingsThreadVisibility.overrideCustomerCompany' is deprecated
- Field 'HelpCenterPortalSettingsThreadVisibility.customerTenants' is deprecated
- Field 'HelpCenterPortalSettingsThreadVisibility.overrideCustomerTenants' is deprecated

**New values in returned enums**

- Enum value 'CONNECTION_PENDING' was added to enum 'ThreadDiscussionAgentStatus'
- Enum value 'NOTE' was added to enum 'WorkflowStepType'
- Enum value 'USER_SEATS' was added to enum 'FeatureKey'
- Enum value 'committed' was added to enum 'BillingCreditBalanceType'

**New methods**

- Field 'memberSeatCapCheck' was added to object type 'Query'
- Field 'threadTimelineEntry' was added to object type 'Query'
- Field 'oauthApp' was added to object type 'Query'
- Field 'oauthApps' was added to object type 'Query'
- Field 'importerAccountLists' was added to object type 'Query'
- Field 'businessHoursOverride' was added to object type 'Query'
- Field 'importBroadcastAudienceChannels' was added to object type 'Mutation'
- Field 'createOAuthApp' was added to object type 'Mutation'
- Field 'updateOAuthApp' was added to object type 'Mutation'
- Field 'enableOAuthApp' was added to object type 'Mutation'
- Field 'disableOAuthApp' was added to object type 'Mutation'
- Field 'deleteOAuthApp' was added to object type 'Mutation'
- Field 'importAccountFieldSchemasFromService' was added to object type 'Mutation'
- Field 'createBusinessHoursOverride' was added to object type 'Mutation'
- Field 'updateBusinessHoursOverride' was added to object type 'Mutation'
- Field 'deleteBusinessHoursOverride' was added to object type 'Mutation'
- Field 'importAccountFieldSchemas' was added to object type 'Mutation'
- Field 'importAccounts' was added to object type 'Mutation'

**Deprecated methods, still available**

- Field 'Query.companies' is deprecated
- Field 'Query.company' is deprecated
- Field 'Query.searchCompanies' is deprecated
- Field 'Query.tenants' is deprecated
- Field 'Query.tenant' is deprecated
- Field 'Query.tenantFieldSchemas' is deprecated
- Field 'Query.searchTenants' is deprecated
- Field 'Query.importerTenantLists' is deprecated
- Field 'Mutation.updateCustomerCompany' is deprecated
- Field 'Mutation.updateThreadTenant' is deprecated
- Field 'Mutation.createTenant' is deprecated
- Field 'Mutation.upsertTenant' is deprecated
- Field 'Mutation.deleteTenant' is deprecated
- Field 'Mutation.addCustomerToTenants' is deprecated
- Field 'Mutation.removeCustomerFromTenants' is deprecated
- Field 'Mutation.setCustomerTenants' is deprecated
- Field 'Mutation.upsertTenantFieldSchema' is deprecated
- Field 'Mutation.deleteTenantFieldSchema' is deprecated
- Field 'Mutation.upsertTenantField' is deprecated
- Field 'Mutation.deleteTenantField' is deprecated
- Field 'Mutation.setupTenantFieldSchemaMapping' is deprecated
- Field 'Mutation.removeTenantFieldSchemaMapping' is deprecated
- Field 'Mutation.upsertCompany' is deprecated
- Field 'Mutation.deleteCompany' is deprecated
- Field 'Mutation.importTenantFieldSchemasFromService' is deprecated
- Field 'Mutation.updateCompanyTier' is deprecated
- Field 'Mutation.updateTenantTier' is deprecated
- Field 'Mutation.updateThreadChannelAssociationTenantMembershipPolicy' is deprecated
- Field 'Mutation.importTenantFieldSchemas' is deprecated
- Field 'Mutation.importTenants' is deprecated
14 changes: 14 additions & 0 deletions .changeset/webhooks-schema-2026-10-06.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@team-plain/webhooks": minor
---

Update the webhook schema to `2026-10-06`, which adds accounts. Eight new events, nothing removed:

- `account.account_created`, `account.account_updated`, `account.account_deleted`
- `account.account_field_created`, `account.account_field_updated`, `account.account_field_deleted`
- `account.account_membership_changed`
- `discussion.turn_stop_requested`

Service level agreements gain two types, `FIRST_RESOLUTION_TIME` (with `firstResolutionTimeMinutes`) and `TOTAL_RESOLUTION_TIME` (with `totalResolutionTimeMinutes`), so handle them in any exhaustive switch on `type`. Every SLA type also gains optional `policy` and `businessHoursSchedules`; `useBusinessHoursOnly` stays but is deprecated in favour of `businessHoursSchedules`.

The package bundles one schema version at a time, so this release only parses payloads whose `webhookMetadata.webhookTargetVersion` is `2026-10-06`. Move your webhook target to `2026-10-06` when you upgrade, or `parsePlainWebhook` returns `PlainWebhookVersionMismatchError`.
95 changes: 42 additions & 53 deletions packages/graphql/src/__tests__/lazy-loading.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CompanyModel } from "../_generated_sdk.js";
import { UserModel } from "../_generated_sdk.js";
import { PlainClient } from "../client.js";
import { getRequestBody, graphqlResponse, mockFetch } from "./helpers.js";

Expand All @@ -10,19 +10,14 @@ describe("lazy-loading relations", () => {
vi.restoreAllMocks();
});

const customerData = {
customer: {
id: "c_123",
externalId: null,
fullName: "Jane Doe",
shortName: null,
email: null,
avatarUrl: null,
assignedToUser: null,
isAnonymous: false,
status: "ACTIVE",
assignedAt: null,
company: { id: "comp_1" },
const companyData = {
company: {
id: "comp_1",
name: "Acme Inc",
logoUrl: null,
domainName: "acme.com",
contractValue: null,
isDeleted: false,
createdAt: { unixTimestamp: "1700000000", iso8601: "2023-11-14T22:13:20Z" },
createdBy: {
__typename: "SystemActor",
Expand All @@ -37,22 +32,20 @@ describe("lazy-loading relations", () => {
workflowExecutionId: null,
workflow: null,
},
markedAsSpamAt: null,
markedAsSpamBy: null,
statusChangedAt: null,
lastIdleAt: null,
identities: [],
tier: null,
threadChannelAssociations: [],
accountOwner: { id: "u_1" },
deletedAt: null,
deletedBy: null,
},
};

const companyData = {
company: {
id: "comp_1",
name: "Acme Inc",
logoUrl: null,
domainName: "acme.com",
contractValue: null,
isDeleted: false,
const userData = {
user: {
id: "u_1",
fullName: "Ada Owner",
publicName: "Ada",
email: "ada@acme.com",
createdAt: { unixTimestamp: "1700000000", iso8601: "2023-11-14T22:13:20Z" },
createdBy: {
__typename: "SystemActor",
Expand All @@ -67,64 +60,60 @@ describe("lazy-loading relations", () => {
workflowExecutionId: null,
workflow: null,
},
tier: null,
threadChannelAssociations: [],
accountOwner: null,
deletedAt: null,
deletedBy: null,
},
};

it("lazy-loads a relation via a separate fetch call", async () => {
fetchMock = mockFetch();
// First call: fetch customer
fetchMock.mockResolvedValueOnce(graphqlResponse(customerData));
// Second call: fetch company (lazy-loaded)
// First call: fetch company
fetchMock.mockResolvedValueOnce(graphqlResponse(companyData));
// Second call: fetch account owner (lazy-loaded)
fetchMock.mockResolvedValueOnce(graphqlResponse(userData));

const client = new PlainClient({ apiKey: "test-key" });
const customer = await client.query.customer({ customerId: "c_123" });
const company = await client.query.company({ companyId: "comp_1" });

const company = await customer.company;
const accountOwner = await company.accountOwner;

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(company).toBeInstanceOf(CompanyModel);
expect(company!.id).toBe("comp_1");
expect(company!.name).toBe("Acme Inc");
expect(company!.domainName).toBe("acme.com");
expect(accountOwner).toBeInstanceOf(UserModel);
expect(accountOwner!.id).toBe("u_1");
expect(accountOwner!.fullName).toBe("Ada Owner");
expect(accountOwner!.email).toBe("ada@acme.com");
});

it("sends correct variables for the lazy-loaded query", async () => {
fetchMock = mockFetch();
fetchMock.mockResolvedValueOnce(graphqlResponse(customerData));
fetchMock.mockResolvedValueOnce(graphqlResponse(companyData));
fetchMock.mockResolvedValueOnce(graphqlResponse(userData));

const client = new PlainClient({ apiKey: "test-key" });
const customer = await client.query.customer({ customerId: "c_123" });
const company = await client.query.company({ companyId: "comp_1" });

await customer.company;
await company.accountOwner;

const secondCallBody = getRequestBody(fetchMock, 1);
expect(secondCallBody.variables).toEqual({ companyId: "comp_1" });
expect(secondCallBody.query).toContain("company");
expect(secondCallBody.variables).toEqual({ userId: "u_1" });
expect(secondCallBody.query).toContain("user");
});

it("returns undefined when relation id is null", async () => {
fetchMock = mockFetch();
const customerWithNoCompany = {
customer: {
...customerData.customer,
company: null,
const companyWithNoOwner = {
company: {
...companyData.company,
accountOwner: null,
},
};
fetchMock.mockResolvedValueOnce(graphqlResponse(customerWithNoCompany));
fetchMock.mockResolvedValueOnce(graphqlResponse(companyWithNoOwner));

const client = new PlainClient({ apiKey: "test-key" });
const customer = await client.query.customer({ customerId: "c_123" });
const company = await client.query.company({ companyId: "comp_1" });

const company = await customer.company;
const accountOwner = await company.accountOwner;

expect(company).toBeUndefined();
expect(accountOwner).toBeUndefined();
// Should not have made a second fetch call
expect(fetchMock).toHaveBeenCalledTimes(1);
});
Expand Down
14 changes: 11 additions & 3 deletions packages/graphql/src/__tests__/query-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe("query execution", () => {
isAnonymous: false,
status: "ACTIVE",
assignedAt: null,
company: { id: "comp_1" },
createdAt: { unixTimestamp: "1700000000", iso8601: "2023-11-14T22:13:20Z" },
createdBy: { __typename: "UserActor", userId: "u_1", user: { id: "u_1" } },
updatedAt: { unixTimestamp: "1700000001", iso8601: "2023-11-14T22:13:21Z" },
Expand Down Expand Up @@ -137,9 +136,18 @@ describe("query execution", () => {
expect(query).toContain("updatedAt");
expect(query).toContain("unixTimestamp");
expect(query).toContain("iso8601");
});

it("selects only { id } for relations, which load lazily", async () => {
fetchMock = mockFetch();
fetchMock.mockResolvedValueOnce(graphqlResponse({ company: null }));
const client = new PlainClient({ apiKey: "test-key" });

await client.query.company({ companyId: "comp_1" }).catch(() => undefined);

// Relations only select { id } (lazy-loading pattern)
expect(query).toMatch(/company(?: @include\(if: true\))?\s*\{\s*id\s*\}/);
expect(sentQuery(getRequestBody(fetchMock))).toMatch(
/accountOwner(?: @include\(if: true\))?\s*\{\s*id\s*\}/,
);
});

it("throws when nullable query returns null", async () => {
Expand Down
Loading
Loading