-
-
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| answerAllCardKeys, | ||
| normalizeEmail, | ||
| 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); | ||
| }); | ||
|
|
||
| it("still requires one of email or externalId", () => { | ||
| const result = PlainCustomerCardRequestSchema.safeParse( | ||
| request({ customer: { id: "c_1", email: null, externalId: null } }) | ||
| ); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects a body with no card keys field", () => { | ||
| expect(PlainCustomerCardRequestSchema.safeParse({ customer: { id: "c_1" } }).success).toBe( | ||
| false | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // Users are stored with a lowercased, trimmed email, so a lookup on the raw value Plain sends | ||
| // would miss a real account whose address differs only in casing or padding. | ||
| describe("normalizeEmail", () => { | ||
| it("lowercases and trims", () => { | ||
| expect(normalizeEmail(" DEV@Example.COM ")).toBe("dev@example.com"); | ||
| }); | ||
|
|
||
| it("leaves an already-normalized address alone", () => { | ||
| expect(normalizeEmail("dev@example.com")).toBe("dev@example.com"); | ||
| }); | ||
|
|
||
| it("is null for absent or empty addresses, so the lookup can be skipped", () => { | ||
| expect(normalizeEmail(null)).toBeNull(); | ||
| expect(normalizeEmail(undefined)).toBeNull(); | ||
| expect(normalizeEmail("")).toBeNull(); | ||
| expect(normalizeEmail(" ")).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("answerAllCardKeys", () => { | ||
| it("adds a no-data card for every unanswered key", () => { | ||
| expect(answerAllCardKeys(["a", "b"], [])).toEqual([ | ||
| { key: "a", components: null }, | ||
| { key: "b", components: null }, | ||
| ]); | ||
| }); | ||
|
|
||
| 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 }, | ||
| { key: "c", components: null }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("ignores extra cards that were not requested", () => { | ||
| const extra = { key: "unrequested", components: [] }; | ||
|
|
||
| expect(answerAllCardKeys([], [extra])).toEqual([extra]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| 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({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Server-only change ships without a release-notes entry This pull request changes only webapp server code ( Repository rule: server-only PRs must add a `.server-changes/` entry
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| cardKeys: z.array(z.string()), | ||
| customer: z | ||
| .object({ | ||
| id: z.string(), | ||
| email: z.string().nullish(), | ||
| externalId: z.string().nullish(), | ||
| }) | ||
| .refine((data) => data.email || data.externalId, { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| message: "Either customer.email or customer.externalId must be provided", | ||
| path: ["customer"], | ||
| }), | ||
| thread: z | ||
| .object({ | ||
| id: z.string(), | ||
| }) | ||
| .nullish(), | ||
| }); | ||
|
|
||
| export type PlainCustomerCardRequest = z.infer<typeof PlainCustomerCardRequestSchema>; | ||
|
|
||
| /** | ||
| * An email in the form `User.email` is stored in. | ||
| * | ||
| * Users are written with `email.toLowerCase().trim()` (see `createUser` / SSO upsert in | ||
| * `models/user.server.ts`), and `User.email` is unique, so an exact lookup on whatever Plain sends | ||
| * would miss a real account whenever the address arrives with different casing or padding — which | ||
| * it can, because for customers created outside our own writes it comes from a sender address. | ||
| * | ||
| * Returns null for an address with nothing left after trimming, so callers can skip the lookup. | ||
| */ | ||
| export function normalizeEmail(email: string | null | undefined): string | null { | ||
| return email?.toLowerCase().trim() || null; | ||
| } | ||
|
|
||
| type NoDataCard = { key: string; components: null }; | ||
|
|
||
| /** | ||
| * 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, | ||
| }) | ||
| ), | ||
|
Comment on lines
+66
to
+71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 No-data cards omit timeToLiveSeconds The fill-in cards from Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| ]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Stale externalId now silently falls back to an email match
Previously an
externalIdshort-circuited the lookup entirely; now a non-matchingexternalIdfalls through to an email lookup (apps/webapp/app/routes/api.v1.plain.customer-cards.ts:123-130). That means a Plain customer whose external id points at a user that no longer exists (or a mistyped id) will render whichever account matches the sender address instead of an empty card. Impersonation is correctly gated offbyExternalId, so no elevated action follows, but agents will see account rows attributed via an unverified email — worth confirming this is the desired support UX.Was this helpful? React with 👍 or 👎 to provide feedback.