Skip to content

Commit 02a1f5e

Browse files
committed
fix(webapp): accept Plain customers without an external id on customer cards
Plain sends customer.externalId as an explicit null rather than omitting the key, and the schema validated it with z.string().optional(), which accepts undefined but rejects null. Every customer we don't set an externalId for got a 400 instead of a card, while the rest worked — so it looked intermittent. email, externalId and thread are now nullish; one of email/externalId is still required, and the existing email fallback resolves these customers. Two related fixes in the same path: - The route returned { cards: [] } when no user matched. Plain records an integration error for any requested key it doesn't get back, so that rendered as a broken card rather than a hidden one. Every requested key is now answered, with components: null where there's no data. - The impersonation link is offered only when the customer matched on externalId, a value we set ourselves. An email match is a weaker claim — the address on a Plain customer isn't verified and, for customers created outside our own writes, comes from whoever sent the message — so email-matched customers now get the account rows without a one-click impersonation link. - The not-found log recorded raw customer identifiers; it keeps presence flags only. The schema and the response helper live in app/utils so they can be unit-tested without pulling in the db and env modules.
1 parent 4569657 commit 02a1f5e

3 files changed

Lines changed: 180 additions & 38 deletions

File tree

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

Lines changed: 34 additions & 38 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,25 @@ 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+
/**
125+
* Impersonation is offered only when the customer was matched on `externalId` — a value we set
126+
* ourselves from `User.id`.
127+
*
128+
* Matching on email is a weaker claim: the address on a Plain customer isn't verified, and for
129+
* customers created outside our own writes it comes from whoever sent the message. Offering a
130+
* one-click impersonation link off the back of that would let an unverified address stand in
131+
* for an account, so email-matched customers get the account rows without it.
132+
*/
133+
const canImpersonate = !!customer.externalId;
134+
135+
// No matching user: still answer every requested key, with no data so Plain hides the cards.
145136
if (!user) {
137+
// Presence flags only — the identifiers themselves don't need to persist in log storage.
146138
logger.info("User not found for Plain customer card request", {
147-
customerId: customer.id,
148-
externalId: customer.externalId,
139+
hasExternalId: !!customer.externalId,
149140
hasEmail: !!customer.email,
150141
});
151-
return json({ cards: [] });
142+
return json({ cards: answerAllCardKeys(cardKeys, []) });
152143
}
153144

154145
// Build cards based on requested cardKeys
@@ -158,10 +149,21 @@ export async function action({ request }: ActionFunctionArgs) {
158149
for (const cardKey of cardKeys) {
159150
switch (cardKey) {
160151
case accountDetailsKey: {
161-
// Generate a signed one-time token for impersonation
162-
const impersonationToken = await generateImpersonationToken(user.id);
163-
// Build the impersonate URL with token for CSRF protection
164-
const impersonateUrl = `${env.APP_ORIGIN}/admin/impersonate?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`;
152+
// Only mint a token when the button will actually be rendered — see `canImpersonate`.
153+
const impersonationComponents = canImpersonate
154+
? [
155+
uiComponent.spacer({ size: "M" }),
156+
uiComponent.divider({ spacingSize: "M" }),
157+
uiComponent.spacer({ size: "M" }),
158+
uiComponent.linkButton({
159+
label: "Impersonate User",
160+
// The one-time token is what protects this link against CSRF.
161+
url: `${env.APP_ORIGIN}/admin/impersonate?impersonate=${user.id}&impersonationToken=${encodeURIComponent(
162+
await generateImpersonationToken(user.id)
163+
)}`,
164+
}),
165+
]
166+
: [];
165167

166168
cards.push({
167169
key: accountDetailsKey,
@@ -241,13 +243,7 @@ export async function action({ request }: ActionFunctionArgs) {
241243
}),
242244
],
243245
}),
244-
uiComponent.spacer({ size: "M" }),
245-
uiComponent.divider({ spacingSize: "M" }),
246-
uiComponent.spacer({ size: "M" }),
247-
uiComponent.linkButton({
248-
label: "Impersonate User",
249-
url: impersonateUrl,
250-
}),
246+
...impersonationComponents,
251247
],
252248
}),
253249
],
@@ -420,13 +416,13 @@ export async function action({ request }: ActionFunctionArgs) {
420416
}
421417

422418
default:
423-
// Unknown card key - skip it
419+
// Unknown card key - answered with no data by answerAllCardKeys below.
424420
logger.info("Unknown card key requested", { cardKey });
425421
break;
426422
}
427423
}
428424

429-
return json({ cards });
425+
return json({ cards: answerAllCardKeys(cardKeys, cards) });
430426
} catch (error) {
431427
logger.error("Error processing Plain customer card request", {
432428
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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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(
53+
(key): NoDataCard => ({
54+
key,
55+
components: null,
56+
})
57+
),
58+
];
59+
}

0 commit comments

Comments
 (0)