Skip to content
Merged
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
6 changes: 4 additions & 2 deletions services/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"dev": "PORT=8990 gulp",
"lint": "eslint --ext .js --max-warnings 5 ./",
"test": "yarn lint"
"test": "yarn lint && mocha --reporter spec"
},
"dependencies": {
"@parameter1/graphql-mongodb-pagination": "^2.2.15",
Expand All @@ -39,6 +39,8 @@
"newrelic": "^8.7.1"
},
"devDependencies": {
"@parameter1/gulp": "^1.2.0"
"@parameter1/gulp": "^1.2.0",
"chai": "^4.3.7",
"mocha": "^6.2.3"
}
}
24 changes: 24 additions & 0 deletions services/graphql/src/definitions/customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ type RapidCustomerIdentification {
customer: Customer
orderId: Int @apiValue
transactionId: Int! @apiValue
"How Omeda located the customer: \`customerId\` when a resolved \`OmedaCustomerId\` was sent (identity resolution bypassed), \`email\` when it fell back to email matching. Resolver-provided, not an Omeda API value."
matchedBy: String
}

input ChangedCustomersQueryInput {
Expand Down Expand Up @@ -263,6 +265,28 @@ input RapidCustomerIdentificationMutationInput {
promoCode: String
"An optional input ID to use when identifying."
inputId: Int
"""
Candidate *encrypted* Omeda customer ids for the customer being identified — every id the caller
holds for this brand. IdentityX stores them as external ids after a member's first
identification, and appends over time, so a member can legitimately hold several.

Each is resolved to its canonical, currently-active numeric id (following Omeda merge chains).
When they **agree** on one customer — including when some are merged away or no longer resolve,
which is the common multi-id case — that id is sent as \`OmedaCustomerId\`, bypassing Omeda's
heuristic identity resolution and guaranteeing the write lands there. This is what prevents
duplicate customers being minted from payloads carrying an email and little else.

When two or more resolve to *simultaneously active* customers, the caller's ids point at genuine
duplicate records and no choice can be made safely — picking one would decide which record
receives every future write — so it falls back to email matching. Do not pre-select an id to
work around this: the stored order is not creation order, and the newest id is typically the
duplicate rather than the real record.

**Any resolution failure falls back to today's email-only behaviour** and is reported, never
raised: a stale, merged, malformed or unknown id must never break identification. \`email\` is
still required and still updates the record's email list either way.
"""
encryptedCustomerIds: [String!] = []
}

input RapidCustomerIdentificationDeploymentTypeInput {
Expand Down
49 changes: 44 additions & 5 deletions services/graphql/src/resolvers/customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { UserInputError } = require('apollo-server-express');
const { get, getAsArray } = require('@parameter1/utils');
const newrelic = require('../newrelic');
const dayjs = require('../dayjs');
const resolveOmedaCustomerId = require('../utils/resolve-omeda-customer-id');

const noticeError = newrelic.noticeError.bind(newrelic);

Expand Down Expand Up @@ -351,6 +352,7 @@ module.exports = {
async rapidCustomerIdentification(_, { input }, { apiClient, repos }) {
const {
email,
encryptedCustomerIds,
phoneNumber,
mobileNumber,
faxNumber,
Expand Down Expand Up @@ -424,6 +426,15 @@ module.exports = {
});
}

// Resolve the caller's stored encrypted ids (if any) to the one canonical, currently-active
// numeric customer id they agree on. Never throws: `null` means "fall back to email
// matching", which is exactly the behaviour every caller had before this field existed.
const resolvedCustomerId = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds,
noticeError,
});

const hasAddress = companyName || regionCode || countryCode || postalCode
|| streetAddress || city || extraAddress;

Expand All @@ -433,6 +444,11 @@ module.exports = {
if (faxNumber) phones.push({ Number: faxNumber, PhoneContactType: 240 });
const body = {
RunProcessor: 1,
// Providing this guarantees Omeda's identity resolution processing is bypassed, so the
// write lands on this customer rather than on whichever record the heuristics pick (or on
// a fresh duplicate). `Emails` below stays unconditional either way -- email is required
// input and still updates the record's email list; it just no longer drives matching.
...(resolvedCustomerId && { OmedaCustomerId: resolvedCustomerId }),
Products: [...productMap].map(([OmedaProductId, Receive]) => {
const subscription = subscriptions.find((obj) => obj.id === OmedaProductId);
return ({
Expand Down Expand Up @@ -495,11 +511,32 @@ module.exports = {
}),
...(promoCode && { PromoCode: promoCode }),
};
// Tracks whether the id actually survived to the write. The retry below strips it, and
// `matchedBy` must report what Omeda really matched on, not what we intended.
let matchedBy = resolvedCustomerId ? 'customerId' : 'email';
const [response] = await Promise.all([
apiClient.resource('customer').storeCustomerAndOrder({
body,
inputId: input.inputId,
}),
(async () => {
const customerResource = apiClient.resource('customer');
try {
return await customerResource.storeCustomerAndOrder({ body, inputId: input.inputId });
} catch (e) {
// Guards the lookup-to-post race: the id resolved cleanly a moment ago, but a merge or
// deactivation landed before the post. Omeda reports that as a structured error rather
// than a silent mismatch, so retry once *without* the id and let email matching handle
// it -- the same outcome the fallback path gives. Any other error is a real failure
// and is re-thrown.
const isIdError = resolvedCustomerId
&& /valid but not active|is not a valid customer|pending deactivation/i.test(e.message);
if (!isIdError) throw e;
noticeError(new Error(`Omeda rejected OmedaCustomerId ${resolvedCustomerId} on store-customer-and-order: ${e.message}. Retrying with email matching.`));
const { OmedaCustomerId, ...withoutId } = body;
matchedBy = 'email';
return customerResource.storeCustomerAndOrder({
body: withoutId,
inputId: input.inputId,
});
}
})(),
(async () => {
if (!deploymentTypeOptInMap.size) return null;
const optInIds = [];
Expand Down Expand Up @@ -527,7 +564,9 @@ module.exports = {
]);
})(),
]);
return response.data;
// `matchedBy` is resolver-provided, not an Omeda API value. Spreading preserves `CustomerId`,
// which the `RapidCustomerIdentification.customer` field resolver destructures.
return { ...response.data, matchedBy };
},
},

Expand Down
154 changes: 154 additions & 0 deletions services/graphql/src/utils/resolve-omeda-customer-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Resolves the caller's stored *encrypted* Omeda customer ids to the one canonical, currently
* active numeric customer id — but **only when they agree**. `rapidCustomerIdentification` then
* sends it as SCAO's `OmedaCustomerId`, bypassing Omeda's heuristic identity resolution.
*
* ## Why this exists
*
* SCAO matched on email alone mints duplicate customers whenever it cannot confidently match.
* Measured on athleticbusiness (Aug 2026): 8 of 11 duplicates were created at the *exact second*
* of a progressive-profile submit, and 7 of 8 were empty shells — no name, no company, no address.
* That is what Omeda writes when the payload carries an email and nothing else to match on. The
* progressive-profile audience is identified-but-not-authenticated members created seconds earlier
* from an email link, whose IdentityX record is email-only by construction, so their payload can
* never carry contact fields. Their encrypted customer id, however, is already stored — written
* within a second of member creation, well before the submit. Sending it is the only identifying
* data those payloads can carry.
*
* ## Why it takes a list, and why convergence is the rule
*
* IdentityX external-id storage appends, so a member accumulates ids over time and can hold several
* for one brand. Those arrive in two flavours, and they need opposite treatment:
*
* - **Merged pairs.** Omeda merges duplicates routinely; the stale id then resolves *to the
* survivor*, because `lookupByEncryptedId` recurses on the "customer id X is valid but not
* active … please use Y" 404, transitively. Every id in such a set converges on one answer, so
* there is nothing to choose and the id is safe to use. Measured: ~32% of ambiguous members.
* - **Live duplicate pairs.** Both records are simultaneously *active* — not a merge, but two real
* customers, typically the good record plus a shell this very bug minted. Measured: 27 of 40
* sampled. Here any choice is a guess, and the guess decides which record receives every future
* write, so we refuse and let email matching continue exactly as it does today.
*
* Hence: resolve all candidates, ignore the ones that are *conclusively* dead, and use the result
* **only if the survivors agree on a single customer**.
*
* ## "Conclusively dead" is narrower than "did not resolve"
*
* A candidate is safe to ignore only when it is known to name no active customer. Two do:
*
* - **Malformed** — not 15 characters, so it can never be a customer id. Filtered before any call
* (this is also what Joi would reject inside `lookupByEncryptedId`).
* - **Not found** — under `errorOnNotFound: false` a genuine 404 resolves *successfully* with an
* empty body, so an absent `data.Id` is a definitive answer, not a failure.
*
* An **error** is neither. Because a real 404 does not throw here, a throw means a timeout, a 5xx
* or a transport failure — i.e. *we do not know what that id points at*. It could be a second
* active customer. Ignoring it and using a sibling's answer would be precisely the guess this
* function exists to avoid, so an unresolved-by-error candidate aborts the whole resolution and
* falls back to email matching. The cost of being wrong here is asymmetric: falling back loses a
* little determinism for one identification, while guessing writes the member onto a record that
* may not be theirs, permanently.
*
* **Do not "just use the newest".** It is both unknowable and wrong. Unknowable because the
* stored array's order is not creation order — measured 13 matching vs 14 differing, and
* `$setUnion` does not guarantee ordering, so position carries no information. Wrong because when
* creation order *is* determined, the older record is the richer one 9 times to 2 among divergent
* pairs: the newest id is the empty shell just minted, the oldest is the member's real customer.
*
* **Deliberately uncached.** The api client this runs against is built with no cache, and it must
* stay that way here: a cached pre-merge record would return exactly the stale id this resolution
* exists to replace.
*
* ## Failure is never fatal
*
* Every failure mode returns `null`, and `null` means the caller sends today's email-only body. A
* stale, merged, malformed or unknown id must never break identification — this mutation sits on
* the blocking path of authentication on every fleet site. The modes: Joi rejects a malformed
* value before any HTTP call (encrypted ids are exactly 15 characters); a hard 404 under
* `errorOnNotFound: false` resolves *successfully* with an empty response, so an absent `data.Id`
* is the signal, matching the guard `customerByEncryptedId` already uses; and any other API error.
*
* @param {object} params
* @param {object} params.apiClient The Omeda API client.
* @param {string[]} [params.encryptedCustomerIds] Candidate encrypted ids for this customer.
* @param {function} params.noticeError Error reporter (New Relic's `noticeError`).
* @returns {Promise<number|null>} The agreed numeric customer id, or `null` to fall back to email.
*/

/**
* Latency guard. Each candidate is one live Omeda GET, and this runs on the blocking path of
* authentication. Beyond this the set is refused outright rather than sampled: resolving an
* arbitrary subset would reintroduce exactly the guess this function exists to avoid. Members
* holding more than four ids for one brand are vanishingly rare (one on abmedia, two on allured).
*/
const MAX_CANDIDATES = 4;

/** Omeda encrypted customer ids are exactly this long; see the api client's attribute schema. */
const ENCRYPTED_ID_LENGTH = 15;

module.exports = async ({ apiClient, encryptedCustomerIds, noticeError } = {}) => {
const supplied = [...new Set((encryptedCustomerIds || [])
.filter((id) => id)
.map((id) => `${id}`.trim()))];
// Not an error: most callers have no stored id yet.
if (!supplied.length) return null;

// Malformed values can never name a customer, so they carry no claim about a write target and
// are dropped rather than allowed to veto a sibling. Mirrors the api client's own
// `encryptedCustomerId` rule (trimmed, exactly 15 chars) -- doing it here keeps a validation
// throw from being indistinguishable from a transport failure below.
const candidates = supplied.filter((id) => id.length === ENCRYPTED_ID_LENGTH);
if (candidates.length !== supplied.length) {
noticeError(new Error(`Ignoring ${supplied.length - candidates.length} malformed encrypted customer id(s): ${supplied.filter((id) => id.length !== ENCRYPTED_ID_LENGTH).join(', ')}.`));
}
if (!candidates.length) return null;

if (candidates.length > MAX_CANDIDATES) {
noticeError(new Error(`Refusing to resolve an Omeda customer from ${candidates.length} candidate encrypted ids (max ${MAX_CANDIDATES}). Falling back to email matching.`));
return null;
}

const resource = apiClient.resource('customer');
const settled = await Promise.all(candidates.map(async (encryptedId) => {
try {
const response = await resource.lookupByEncryptedId({
encryptedId,
// Follow merge chains to the surviving record. This is what makes a stale id usable.
reQueryOnInactive: true,
// A genuine miss must resolve empty, not throw -- "not found" is an answer, not a failure.
errorOnNotFound: false,
});
const id = (response && response.data ? response.data.Id : null) || null;
return id ? { state: 'active', id } : { state: 'dead' };
} catch (e) {
noticeError(new Error(`Unable to resolve Omeda customer from encrypted id ${encryptedId}: ${e.message}.`));
return { state: 'unknown' };
}
}));

// We do not know what an errored candidate points at, and it could be a second active customer.
// Refuse rather than let a sibling's answer stand in for it.
if (settled.some(({ state }) => state === 'unknown')) {
noticeError(new Error(`Could not resolve every candidate encrypted id (${candidates.join(', ')}); cannot rule out a second active customer. Falling back to email matching.`));
return null;
}

// Conclusively-dead ids are ignored, not disqualifying: a merged-away or unknown id alongside a
// live one leaves exactly one real answer.
const resolved = [...new Set(settled.filter((r) => r.state === 'active').map((r) => r.id))];

if (!resolved.length) {
noticeError(new Error(`Unable to resolve an Omeda customer from ${candidates.length} encrypted id(s): none are active. Falling back to email matching.`));
return null;
}

if (resolved.length > 1) {
// Two or more simultaneously-active customers for one member. Choosing would decide which
// record receives every future write, so refuse -- email matching continues as it does today.
// These are the pairs that need merging in Omeda; this is the signal that says which.
noticeError(new Error(`Omeda customer ids ${resolved.join(', ')} are all active for the same member (encrypted ids ${candidates.join(', ')}); cannot choose a write target. Falling back to email matching.`));
return null;
}

return resolved[0];
};
2 changes: 2 additions & 0 deletions services/graphql/test/mocha.opts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--recursive
--timeout 2000
Loading
Loading