From dd0462a5f90a552d1172710a1288ae375bdcf33e Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 22:38:38 +1000 Subject: [PATCH] fix(orders): decode • so item options separate again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `•` 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. --- .../fixtures/orders/real/dd-bull-entity.html | 3 ++ src/__tests__/unit/order-ingestion.test.ts | 33 +++++++++++++++++++ src/lib/order-parse.ts | 27 +++++++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/fixtures/orders/real/dd-bull-entity.html diff --git a/src/__tests__/fixtures/orders/real/dd-bull-entity.html b/src/__tests__/fixtures/orders/real/dd-bull-entity.html new file mode 100644 index 0000000..7ebc40a --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-bull-entity.html @@ -0,0 +1,3 @@ + DoorDash
DOORDASH
+ + \ No newline at end of file diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts index fc7aa68..58839b9 100644 --- a/src/__tests__/unit/order-ingestion.test.ts +++ b/src/__tests__/unit/order-ingestion.test.ts @@ -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", () => { + // "Sweet & Sour Crunch (…)
• Sweet & Sour + // Crunch 12 Pieces". • 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("•"); + expect(all).not.toContain("&"); + + 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 &bull; alone rather than turning it into a bullet", () => { + // Why & resolves last: decoding it first would rewrite text that was + // deliberately escaped. + const o = parseOrderHTML( + html("dd-01").replace("Mad Mex", "A&amp;bull;B"), + meta() + ); + expect(JSON.stringify(o)).not.toContain("A•B".replace("•", "•")); + }); +}); diff --git a/src/lib/order-parse.ts b/src/lib/order-parse.ts index 113980c..cd08bcc 100644 --- a/src/lib/order-parse.ts +++ b/src/lib/order-parse.ts @@ -101,14 +101,35 @@ export class OrderParseError extends Error { const stripTags = (s: string) => s.replace(/<[^>]+>/g, " "); +/** + * Entity decoding, ordered so `&` resolves last. + * + * `•` 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) • 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 $ came to be listed individually while its neighbours were not. + * + * `&` goes last because decoding it first turns a literal "&bull;" — + * text that should stay as written — into a bullet. + */ const decodeEntities = (s: string) => s .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/'|'/gi, "'") .replace(/"/gi, '"') - .replace(/$/g, "$") - .replace(/…/gi, "…"); + .replace(/…/gi, "…") + .replace(/•/gi, "•") + .replace(/·/gi, "·") + .replace(/–/gi, "–") + .replace(/—/gi, "—") + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))) + .replace(/&/gi, "&"); const collapse = (s: string) => s.replace(/\s+/g, " ").trim();