-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(webapp): accept Plain customers without an external id on customer cards #4575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+292
−44
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
02a1f5e
fix(webapp): accept Plain customers without an external id on custome…
isshaddad b41af49
fix(webapp): normalize the email before looking up a card's customer
isshaddad 7367118
fix(webapp): cap how long an empty customer card is cached, add relea…
isshaddad d1cb098
fix(webapp): look a card's customer up by both sent and lowercased email
isshaddad 78330f2
Merge branch 'main' into fix/plain-customer-cards-null-external-id
isshaddad 98d6b40
fix(webapp): stop rejecting card requests for customers with no ident…
isshaddad File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: fix | ||
| --- | ||
|
|
||
| Fixed support threads showing no account details for some customers, so the team can see your plan, organizations and projects when you get in touch. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| answerAllCardKeys, | ||
| emailLookupCandidates, | ||
| PlainCustomerCardRequestSchema, | ||
| } from "./plainCustomerCards"; | ||
|
|
||
| const request = (overrides: Record<string, unknown> = {}) => ({ | ||
| cardKeys: ["account-details"], | ||
| customer: { id: "c_1", email: "dev@example.com", externalId: "user_1" }, | ||
| ...overrides, | ||
| }); | ||
|
|
||
| describe("PlainCustomerCardRequestSchema", () => { | ||
| it("accepts a fully populated request", () => { | ||
| expect( | ||
| PlainCustomerCardRequestSchema.safeParse(request({ thread: { id: "th_1" } })).success | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| // Plain sends explicit nulls rather than omitting these keys. Rejecting them meant every | ||
| // customer created outside our own writes got a 400 instead of a card. | ||
| it("accepts a null externalId when there is an email", () => { | ||
| const result = PlainCustomerCardRequestSchema.safeParse( | ||
| request({ customer: { id: "c_1", email: "dev@example.com", externalId: null } }) | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| }); | ||
|
|
||
| it("accepts a null email when there is an externalId", () => { | ||
| const result = PlainCustomerCardRequestSchema.safeParse( | ||
| request({ customer: { id: "c_1", email: null, externalId: "user_1" } }) | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| }); | ||
|
|
||
| it("accepts a null thread", () => { | ||
| expect(PlainCustomerCardRequestSchema.safeParse(request({ thread: null })).success).toBe(true); | ||
| }); | ||
|
|
||
| it("accepts an omitted thread", () => { | ||
| expect(PlainCustomerCardRequestSchema.safeParse(request()).success).toBe(true); | ||
| }); | ||
|
|
||
| // A contact created by an integration can have neither identifier. There's nothing to look up, | ||
| // but rejecting it would make Plain record an integration error rather than hide the card. | ||
| it("accepts a customer with neither email nor externalId", () => { | ||
| const result = PlainCustomerCardRequestSchema.safeParse( | ||
| request({ customer: { id: "c_1", email: null, externalId: null } }) | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects a body with no card keys field", () => { | ||
| expect(PlainCustomerCardRequestSchema.safeParse({ customer: { id: "c_1" } }).success).toBe( | ||
| false | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // `User.email` casing depends on the signup path: the SSO upsert lowercases, magic-link and OAuth | ||
| // store what the provider gave. Either candidate alone misses one of those populations. | ||
| describe("emailLookupCandidates", () => { | ||
| it("tries the address as sent before its lowercased form", () => { | ||
| // Finds a magic-link user stored with capitals, then an SSO user stored lowercased. | ||
| expect(emailLookupCandidates("Dev@Example.com")).toEqual([ | ||
| "Dev@Example.com", | ||
| "dev@example.com", | ||
| ]); | ||
| }); | ||
|
|
||
| it("yields a single candidate when the address is already lowercase", () => { | ||
| expect(emailLookupCandidates("dev@example.com")).toEqual(["dev@example.com"]); | ||
| }); | ||
|
|
||
| it("trims before comparing, so padding doesn't produce a duplicate candidate", () => { | ||
| expect(emailLookupCandidates(" dev@example.com ")).toEqual(["dev@example.com"]); | ||
| }); | ||
|
|
||
| it("is empty for absent or blank addresses, so the lookup can be skipped", () => { | ||
| expect(emailLookupCandidates(null)).toEqual([]); | ||
| expect(emailLookupCandidates(undefined)).toEqual([]); | ||
| expect(emailLookupCandidates("")).toEqual([]); | ||
| expect(emailLookupCandidates(" ")).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("answerAllCardKeys", () => { | ||
| it("adds a no-data card for every unanswered key", () => { | ||
| expect(answerAllCardKeys(["a", "b"], [])).toEqual([ | ||
| { key: "a", components: null, timeToLiveSeconds: 60 }, | ||
| { key: "b", components: null, timeToLiveSeconds: 60 }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("leaves answered cards untouched", () => { | ||
| const answered = { key: "a", components: [{ componentText: { text: "hi" } }] }; | ||
|
|
||
| expect(answerAllCardKeys(["a"], [answered])).toEqual([answered]); | ||
| }); | ||
|
|
||
| it("fills only the gaps, keeping answered cards first", () => { | ||
| const answered = { key: "b", components: [] }; | ||
|
|
||
| expect(answerAllCardKeys(["a", "b", "c"], [answered])).toEqual([ | ||
| answered, | ||
| { key: "a", components: null, timeToLiveSeconds: 60 }, | ||
| { key: "c", components: null, timeToLiveSeconds: 60 }, | ||
| ]); | ||
| }); | ||
|
|
||
| // Omitting the TTL would fall back to the card's configured default, keeping an empty card in | ||
| // Plain's cache after the customer becomes resolvable. | ||
| it("caps how long an empty card is cached", () => { | ||
| const [filler] = answerAllCardKeys(["a"], []); | ||
|
|
||
| expect(filler).toMatchObject({ timeToLiveSeconds: 60 }); | ||
| }); | ||
|
|
||
| it("ignores extra cards that were not requested", () => { | ||
| const extra = { key: "unrequested", components: [] }; | ||
|
|
||
| expect(answerAllCardKeys([], [extra])).toEqual([extra]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| /** | ||
| * The request Plain sends to a customer card endpoint. | ||
| * | ||
| * `email`, `externalId` and `thread` are nullish rather than optional because Plain sends these | ||
| * keys as explicit nulls rather than omitting them — `externalId` whenever the customer was | ||
| * created outside our own writes (its Slack integration, for one), `thread` when the card is | ||
| * loaded on the customer page rather than in a thread. `.optional()` accepts `undefined` but | ||
| * rejects `null`, which failed the whole request before any lookup could run. | ||
| */ | ||
| export const PlainCustomerCardRequestSchema = z.object({ | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| cardKeys: z.array(z.string()), | ||
| // A customer with neither an email nor an external id is valid input, not a malformed request: | ||
| // a contact created by an integration can legitimately have neither. There's nothing to look up, | ||
| // so the route answers every key with no data — rejecting it would make Plain record an | ||
| // integration error, which is the failure this schema change exists to remove. | ||
| customer: z.object({ | ||
| id: z.string(), | ||
| email: z.string().nullish(), | ||
| externalId: z.string().nullish(), | ||
| }), | ||
| thread: z | ||
| .object({ | ||
| id: z.string(), | ||
| }) | ||
| .nullish(), | ||
| }); | ||
|
|
||
| export type PlainCustomerCardRequest = z.infer<typeof PlainCustomerCardRequestSchema>; | ||
|
|
||
| /** | ||
| * The values to try, in order, when looking a user up by email. | ||
| * | ||
| * `User.email` is not stored consistently cased: the SSO upsert writes | ||
| * `email.toLowerCase().trim()`, while `findOrCreateMagicLinkUser` and the OAuth paths store | ||
| * whatever the provider gave us. So neither an exact match nor a lowercased one finds everybody — | ||
| * exact misses an SSO user whose address arrives capitalised, lowercased misses a magic-link user | ||
| * stored with capitals. | ||
| * | ||
| * Hence two candidates: the address as sent (trimmed), then its lowercased form. Both are exact | ||
| * matches, so each uses the unique index on `email` — a case-insensitive query would not, and this | ||
| * table is far too big to scan. The common case hits on the first. | ||
| * | ||
| * Empty when there's no usable address, so callers can skip the lookup entirely. | ||
| */ | ||
| export function emailLookupCandidates(email: string | null | undefined): string[] { | ||
| const asSent = email?.trim(); | ||
| if (!asSent) return []; | ||
|
|
||
| const lowercased = asSent.toLowerCase(); | ||
| return asSent === lowercased ? [asSent] : [asSent, lowercased]; | ||
| } | ||
|
|
||
| type NoDataCard = { key: string; components: null; timeToLiveSeconds: number }; | ||
|
|
||
| /** | ||
| * How long Plain may cache a card we had no data for. | ||
| * | ||
| * Explicit rather than omitted: omitting the field falls back to the TTL configured for that card | ||
| * in Plain's settings, so a customer who becomes resolvable — an external id gets set, or someone | ||
| * signs up with that address — would keep showing an empty card for however long that default is. | ||
| * Short enough to recover promptly, long enough not to re-ask on every glance at a thread. | ||
| */ | ||
| const NO_DATA_TTL_SECONDS = 60; | ||
|
|
||
| /** | ||
| * Fills in a `components: null` card for every requested key that wasn't answered. | ||
| * | ||
| * Plain records an integration error against any key it asked for and didn't get back, so a | ||
| * partial response surfaces in the support app as a broken card. `components: null` is how you | ||
| * say "this card has no data" and have Plain hide it instead. | ||
| */ | ||
| export function answerAllCardKeys<TCard extends { key: string }>( | ||
| cardKeys: string[], | ||
| cards: TCard[] | ||
| ): (TCard | NoDataCard)[] { | ||
| const answered = new Set(cards.map((card) => card.key)); | ||
|
|
||
| return [ | ||
| ...cards, | ||
| ...cardKeys | ||
| .filter((key) => !answered.has(key)) | ||
| .map( | ||
| (key): NoDataCard => ({ | ||
| key, | ||
| components: null, | ||
| timeToLiveSeconds: NO_DATA_TTL_SECONDS, | ||
| }) | ||
| ), | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| ]; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.