4b4f6f2071
Plain sends `customer.externalId` as an explicit `null` rather than
omitting the key. The schema validated it with `z.string().optional()`,
which accepts `undefined` but rejects `null`, so every customer we don't
set an `externalId` for got a 400 instead of a card — while the rest
worked, which made it look intermittent.
`email`, `externalId` and `thread` are now `nullish`. One of
email/externalId is still required, and the route's existing email
fallback resolves these customers.
Three 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
surfaced 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, since the address on a Plain customer isn't verified and for
customers created outside our own writes it comes from whoever sent the
message. Email-matched customers get the account rows without a
one-click impersonation link.
- The not-found log recorded raw customer identifiers; it now keeps
presence flags only.
The schema and the response helper moved to
`app/utils/plainCustomerCards.ts` so they can be unit-tested without
pulling in the db and env modules.
## Testing
`app/utils/plainCustomerCards.test.ts` — 11 tests covering the null
shapes, the every-key-answered response, and the missing-vs-zero
distinction. Verified locally.
Split out of #4571, which bundled this with an unrelated impersonation
fix.
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
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({
|
|
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,
|
|
})
|
|
),
|
|
];
|
|
}
|