Skip to content

Commit cb6088c

Browse files
committed
fix(webapp): accept Plain customers without an external id on customer cards
1 parent 8819e25 commit cb6088c

3 files changed

Lines changed: 149 additions & 25 deletions

File tree

apps/webapp/app/routes/api.v1.plain.customer-cards.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
11
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { timingSafeEqual } from "crypto";
33
import { uiComponent } from "@team-plain/ui-components";
4-
import { z } from "zod";
54
import { prisma } from "~/db.server";
65
import { env } from "~/env.server";
76
import { logger } from "~/services/logger.server";
87
import { generateImpersonationToken } from "~/services/impersonation.server";
9-
10-
// Schema for the request body from Plain
11-
const PlainCustomerCardRequestSchema = z.object({
12-
cardKeys: z.array(z.string()),
13-
customer: z
14-
.object({
15-
id: z.string(),
16-
email: z.string().optional(),
17-
externalId: z.string().optional(),
18-
})
19-
.refine((data) => data.email || data.externalId, {
20-
message: "Either customer.email or customer.externalId must be provided",
21-
path: ["customer"],
22-
}),
23-
thread: z
24-
.object({
25-
id: z.string(),
26-
})
27-
.optional(),
28-
});
8+
import { answerAllCardKeys, PlainCustomerCardRequestSchema } from "~/utils/plainCustomerCards";
299

3010
function sanitizeHeaders(
3111
request: Request,
@@ -141,14 +121,14 @@ export async function action({ request }: ActionFunctionArgs) {
141121

142122
const user = where ? await prisma.user.findFirst({ where, include: userInclude }) : null;
143123

144-
// If user not found, return empty cards
124+
// No matching user: still answer every requested key, with no data so Plain hides the cards.
145125
if (!user) {
146126
logger.info("User not found for Plain customer card request", {
147127
customerId: customer.id,
148128
externalId: customer.externalId,
149129
hasEmail: !!customer.email,
150130
});
151-
return json({ cards: [] });
131+
return json({ cards: answerAllCardKeys(cardKeys, []) });
152132
}
153133

154134
// Build cards based on requested cardKeys
@@ -420,13 +400,13 @@ export async function action({ request }: ActionFunctionArgs) {
420400
}
421401

422402
default:
423-
// Unknown card key - skip it
403+
// Unknown card key - answered with no data by answerAllCardKeys below.
424404
logger.info("Unknown card key requested", { cardKey });
425405
break;
426406
}
427407
}
428408

429-
return json({ cards });
409+
return json({ cards: answerAllCardKeys(cardKeys, cards) });
430410
} catch (error) {
431411
logger.error("Error processing Plain customer card request", {
432412
error: error instanceof Error ? error.message : String(error),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from "vitest";
2+
import { answerAllCardKeys, PlainCustomerCardRequestSchema } from "./plainCustomerCards";
3+
4+
const request = (overrides: Record<string, unknown> = {}) => ({
5+
cardKeys: ["account-details"],
6+
customer: { id: "c_1", email: "dev@example.com", externalId: "user_1" },
7+
...overrides,
8+
});
9+
10+
describe("PlainCustomerCardRequestSchema", () => {
11+
it("accepts a fully populated request", () => {
12+
expect(
13+
PlainCustomerCardRequestSchema.safeParse(request({ thread: { id: "th_1" } })).success
14+
).toBe(true);
15+
});
16+
17+
// Plain sends explicit nulls rather than omitting these keys. Rejecting them meant every
18+
// customer created outside our own writes got a 400 instead of a card.
19+
it("accepts a null externalId when there is an email", () => {
20+
const result = PlainCustomerCardRequestSchema.safeParse(
21+
request({ customer: { id: "c_1", email: "dev@example.com", externalId: null } })
22+
);
23+
24+
expect(result.success).toBe(true);
25+
});
26+
27+
it("accepts a null email when there is an externalId", () => {
28+
const result = PlainCustomerCardRequestSchema.safeParse(
29+
request({ customer: { id: "c_1", email: null, externalId: "user_1" } })
30+
);
31+
32+
expect(result.success).toBe(true);
33+
});
34+
35+
it("accepts a null thread", () => {
36+
expect(PlainCustomerCardRequestSchema.safeParse(request({ thread: null })).success).toBe(true);
37+
});
38+
39+
it("accepts an omitted thread", () => {
40+
expect(PlainCustomerCardRequestSchema.safeParse(request()).success).toBe(true);
41+
});
42+
43+
it("still requires one of email or externalId", () => {
44+
const result = PlainCustomerCardRequestSchema.safeParse(
45+
request({ customer: { id: "c_1", email: null, externalId: null } })
46+
);
47+
48+
expect(result.success).toBe(false);
49+
});
50+
51+
it("rejects a body with no card keys field", () => {
52+
expect(PlainCustomerCardRequestSchema.safeParse({ customer: { id: "c_1" } }).success).toBe(
53+
false
54+
);
55+
});
56+
});
57+
58+
describe("answerAllCardKeys", () => {
59+
it("adds a no-data card for every unanswered key", () => {
60+
expect(answerAllCardKeys(["a", "b"], [])).toEqual([
61+
{ key: "a", components: null },
62+
{ key: "b", components: null },
63+
]);
64+
});
65+
66+
it("leaves answered cards untouched", () => {
67+
const answered = { key: "a", components: [{ componentText: { text: "hi" } }] };
68+
69+
expect(answerAllCardKeys(["a"], [answered])).toEqual([answered]);
70+
});
71+
72+
it("fills only the gaps, keeping answered cards first", () => {
73+
const answered = { key: "b", components: [] };
74+
75+
expect(answerAllCardKeys(["a", "b", "c"], [answered])).toEqual([
76+
answered,
77+
{ key: "a", components: null },
78+
{ key: "c", components: null },
79+
]);
80+
});
81+
82+
it("ignores extra cards that were not requested", () => {
83+
const extra = { key: "unrequested", components: [] };
84+
85+
expect(answerAllCardKeys([], [extra])).toEqual([extra]);
86+
});
87+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* The request Plain sends to a customer card endpoint.
5+
*
6+
* `email`, `externalId` and `thread` are nullish rather than optional because Plain sends these
7+
* keys as explicit nulls rather than omitting them — `externalId` whenever the customer was
8+
* created outside our own writes (its Slack integration, for one), `thread` when the card is
9+
* loaded on the customer page rather than in a thread. `.optional()` accepts `undefined` but
10+
* rejects `null`, which failed the whole request before any lookup could run.
11+
*/
12+
export const PlainCustomerCardRequestSchema = z.object({
13+
cardKeys: z.array(z.string()),
14+
customer: z
15+
.object({
16+
id: z.string(),
17+
email: z.string().nullish(),
18+
externalId: z.string().nullish(),
19+
})
20+
.refine((data) => data.email || data.externalId, {
21+
message: "Either customer.email or customer.externalId must be provided",
22+
path: ["customer"],
23+
}),
24+
thread: z
25+
.object({
26+
id: z.string(),
27+
})
28+
.nullish(),
29+
});
30+
31+
export type PlainCustomerCardRequest = z.infer<typeof PlainCustomerCardRequestSchema>;
32+
33+
type NoDataCard = { key: string; components: null };
34+
35+
/**
36+
* Fills in a `components: null` card for every requested key that wasn't answered.
37+
*
38+
* Plain records an integration error against any key it asked for and didn't get back, so a
39+
* partial response surfaces in the support app as a broken card. `components: null` is how you
40+
* say "this card has no data" and have Plain hide it instead.
41+
*/
42+
export function answerAllCardKeys<TCard extends { key: string }>(
43+
cardKeys: string[],
44+
cards: TCard[]
45+
): (TCard | NoDataCard)[] {
46+
const answered = new Set(cards.map((card) => card.key));
47+
48+
return [
49+
...cards,
50+
...cardKeys
51+
.filter((key) => !answered.has(key))
52+
.map((key): NoDataCard => ({
53+
key,
54+
components: null,
55+
})),
56+
];
57+
}

0 commit comments

Comments
 (0)