fix(orders): decode • so item options separate again
ci / lint-test (push) Successful in 53s

`•` was missing from the entity table, and that was not cosmetic.
DoorDash separates an item's name from its options with a bullet and
parseDoorDashLineItems splits on the literal "•" — so left encoded, the
split never happened and the line collapsed into the description:
"Bucket and Side Pack (Meal Deals) • Hot Bucket • Chips" with
options []. The entity showed on screen and the structure behind it was
gone.

Numeric entities are now decoded generically rather than one at a time,
which is how $ came to be listed individually while its neighbours
were not, and & resolves last so a literal "•" stays as
written instead of turning into a bullet.

Repaired the 47 stored rows by re-parsing the captured email behind each
one rather than string-replacing the entity, since a replacement would
have fixed the display and left options [] underneath. Rehearsed first:
all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts
changed. Old values kept in dump/rollback-line-items-20260728-223737.json.

Pre-existing — 22 rows predate today — but the pre-cutover backfill more
than doubled the affected rows, which is what surfaced it. Verified in
the Order details panel, not in SQL.
This commit is contained in:
2026-07-28 22:38:38 +10:00
parent 3144cf3176
commit dd0462a5f9
3 changed files with 60 additions and 3 deletions
File diff suppressed because one or more lines are too long
@@ -488,3 +488,36 @@ describe("payment legs", () => {
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
});
});
describe("HTML entities in line items", () => {
it("splits options on an encoded bullet instead of swallowing it", () => {
// "<b>Sweet &amp; Sour Crunch</b> (…)<br><font>&bull; Sweet &amp; Sour
// Crunch 12 Pieces</font>". &bull; was not in the decode table, and
// parseDoorDashLineItems splits on the literal "•" — so the option never
// separated and the entity was rendered raw in the Order details panel.
const o = parseOrderHTML(
html("dd-bull-entity"),
meta({ subject: "Order Confirmation for Siddharth from Red Rooster" })
);
const all = JSON.stringify(o.line_items);
expect(all).not.toContain("&bull;");
expect(all).not.toContain("&amp;");
const item = o.line_items.find((i) => i.description.startsWith("Sweet"));
expect(item).toBeDefined();
// The ampersand decodes, and the bullet becomes a boundary, not text.
expect(item!.description).toContain("Sweet & Sour Crunch");
expect(item!.description).not.toContain("•");
expect(item!.options.length).toBeGreaterThan(0);
});
it("leaves a literal &amp;bull; alone rather than turning it into a bullet", () => {
// Why &amp; resolves last: decoding it first would rewrite text that was
// deliberately escaped.
const o = parseOrderHTML(
html("dd-01").replace("Mad Mex", "A&amp;amp;bull;B"),
meta()
);
expect(JSON.stringify(o)).not.toContain("A&bull;B".replace("&bull;", "•"));
});
});
+24 -3
View File
@@ -101,14 +101,35 @@ export class OrderParseError extends Error {
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
/**
* Entity decoding, ordered so `&amp;` resolves last.
*
* `&bull;` was missing, and it is not a cosmetic omission: DoorDash separates
* an item's name from its options with a bullet, and parseDoorDashLineItems
* splits on the literal "•". Left encoded, the split never happens and the
* whole line collapses into the description — "Fire Extinguisher (Chicken
* Burgers) &bull; Regular" instead of a name plus one option. So the entity
* showed up on screen AND the structure behind it was lost.
*
* Numeric entities are decoded generically rather than one at a time, which is
* how &#36; came to be listed individually while its neighbours were not.
*
* `&amp;` goes last because decoding it first turns a literal "&amp;bull;" —
* text that should stay as written — into a bullet.
*/
const decodeEntities = (s: string) =>
s
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&#39;|&apos;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(/&#36;/g, "$")
.replace(/&hellip;/gi, "");
.replace(/&hellip;/gi, "")
.replace(/&bull;/gi, "")
.replace(/&middot;/gi, "·")
.replace(/&ndash;/gi, "")
.replace(/&mdash;/gi, "—")
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.replace(/&amp;/gi, "&");
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();