Skip to content
Open
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
95 changes: 51 additions & 44 deletions apps/webapp/app/routes/api.v1.plain.customer-cards.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { timingSafeEqual } from "crypto";
import { uiComponent } from "@team-plain/ui-components";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { generateImpersonationToken } from "~/services/impersonation.server";

// Schema for the request body from Plain
const PlainCustomerCardRequestSchema = z.object({
cardKeys: z.array(z.string()),
customer: z
.object({
id: z.string(),
email: z.string().optional(),
externalId: z.string().optional(),
})
.refine((data) => data.email || data.externalId, {
message: "Either customer.email or customer.externalId must be provided",
path: ["customer"],
}),
thread: z
.object({
id: z.string(),
})
.optional(),
});
import {
answerAllCardKeys,
normalizeEmail,
PlainCustomerCardRequestSchema,
} from "~/utils/plainCustomerCards";

function sanitizeHeaders(
request: Request,
Expand Down Expand Up @@ -133,22 +117,40 @@ export async function action({ request }: ActionFunctionArgs) {
},
};

const where = customer.externalId
? { id: customer.externalId }
: customer.email
? { email: customer.email }
: null;
// The external id is ours (`User.id`), so it's tried first. Falling back to email when it
// doesn't resolve covers a stale id — one naming a user row that no longer exists — instead of
// leaving the card blank for a customer we could still identify.
const byExternalId = customer.externalId
? await prisma.user.findFirst({ where: { id: customer.externalId }, include: userInclude })
: null;

const email = normalizeEmail(customer.email);
const user =
byExternalId ??
(email ? await prisma.user.findFirst({ where: { email }, include: userInclude }) : null);
Comment on lines +120 to +130

Copy link
Copy Markdown
Contributor

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 externalId short-circuited the lookup entirely; now a non-matching externalId falls 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 off byExternalId, so no elevated action follows, but agents will see account rows attributed via an unverified email — worth confirming this is the desired support UX.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const user = where ? await prisma.user.findFirst({ where, include: userInclude }) : null;
/**
* Impersonation is offered only when the customer matched on `externalId` — a value we set
* ourselves from `User.id`.
*
* Matching on email is a weaker claim: the address on a Plain customer isn't verified, and for
* customers created outside our own writes it comes from whoever sent the message. Offering a
* one-click impersonation link off the back of that would let an unverified address stand in
* for an account, so email-matched customers get the account rows without it.
*
* Derived from which lookup actually matched, not from whether an external id was *sent* — an
* id that misses and falls through to email must not unlock impersonation.
*/
const canImpersonate = Boolean(byExternalId);

// If user not found, return empty cards
// No matching user: still answer every requested key, with no data so Plain hides the cards.
if (!user) {
// Presence flags only — the identifiers themselves don't need to persist in log storage.
logger.info("User not found for Plain customer card request", {
customerId: customer.id,
externalId: customer.externalId,
hasExternalId: !!customer.externalId,
hasEmail: !!customer.email,
});
return json({ cards: [] });
return json({ cards: answerAllCardKeys(cardKeys, []) });
}

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

cards.push({
key: accountDetailsKey,
Expand Down Expand Up @@ -241,13 +254,7 @@ export async function action({ request }: ActionFunctionArgs) {
}),
],
}),
uiComponent.spacer({ size: "M" }),
uiComponent.divider({ spacingSize: "M" }),
uiComponent.spacer({ size: "M" }),
uiComponent.linkButton({
label: "Impersonate User",
url: impersonateUrl,
}),
...impersonationComponents,
],
}),
],
Expand Down Expand Up @@ -420,13 +427,13 @@ export async function action({ request }: ActionFunctionArgs) {
}

default:
// Unknown card key - skip it
// Unknown card key - answered with no data by answerAllCardKeys below.
logger.info("Unknown card key requested", { cardKey });
break;
}
}

return json({ cards });
return json({ cards: answerAllCardKeys(cardKeys, cards) });
} catch (error) {
logger.error("Error processing Plain customer card request", {
error: error instanceof Error ? error.message : String(error),
Expand Down
110 changes: 110 additions & 0 deletions apps/webapp/app/utils/plainCustomerCards.test.ts
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]);
});
});
73 changes: 73 additions & 0 deletions apps/webapp/app/utils/plainCustomerCards.ts
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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 (apps/webapp/app/routes/api.v1.plain.customer-cards.ts, apps/webapp/app/utils/plainCustomerCards.ts) without adding the required release-notes file, so the fix will be missing from the user-visible release notes.
Impact: Users reading the release notes won't see that support cards now work for customers we don't set an id for.

Repository rule: server-only PRs must add a `.server-changes/` entry

AGENTS.md and CONTRIBUTING.md state that when modifying only server components (apps/webapp/, apps/supervisor/, etc.) with no package changes, a .server-changes/ markdown file must be added (frontmatter area: webapp, type: fix, plus a one-line user-facing description — see .server-changes/README.md:7-39). The PR touches only apps/webapp/** and adds no such file.

Prompt for agents
This PR only changes server code under apps/webapp, so per AGENTS.md / CONTRIBUTING.md / .server-changes/README.md it needs a new markdown file in .server-changes/ (e.g. .server-changes/fix-plain-customer-cards.md) with frontmatter `area: webapp` and `type: fix`, and a one-line, user-facing body describing that support customer cards now load for customers without an external id.
Open in Devin Review

Was 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, {
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 No-data cards omit timeToLiveSeconds

The fill-in cards from answerAllCardKeys contain only { key, components: null } while the real cards specify timeToLiveSeconds (15/300). Depending on Plain's defaults, a missing TTL may mean the empty card is cached with a default lifetime, so a customer who becomes resolvable (e.g. after an externalId is set) may keep showing a hidden card until the default TTL elapses. Worth checking Plain's documented default TTL for card responses.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

];
}
Loading