From 1acae2f20c9cb56122847505a855d0118048cf70 Mon Sep 17 00:00:00 2001 From: justin Date: Sat, 20 Jun 2026 16:14:44 -0500 Subject: [PATCH] healthcare: fix 4 bugs in segment-assignment + free-check email Found during a bug-review pass of the one-email-per-provider work: 1. assign_all overwrite bug: an email on MULTIPLE rows (shared practice inbox / multiple NPIs -- 2,592 such emails, 299 with mixed status) was assigned by the LAST row, so a less-urgent row could clobber an urgent one (overdue -> free check). Now keeps the most-urgent (lowest-priority) assignment. 2. warm_segment double-import + wrong-row render: all of an email's rows passed the candidate filter, so it could be imported twice (over-counting the slice) and attribs_for could render a sibling row's blank due-date in the overdue email. Now requires row_matches(seg) for the specific row AND dedupes by email (one row per email). 3. free-check email rendered broken text ('last updated on -- about years ago', 'Last updated . ~ yrs ago') for any provider whose NPPES date isn't cached yet (the free check goes to everyone, and the fill is gradual). Wrapped the example sentence + official-record card in listmonk {{ if .nppes_last_updated }}...{{ else }}...{{ end }}; added a date-free else branch. altbody keeps the conditionals (listmonk evaluates body+altbody), and the test/preview renderer gained a minimal {{ if/else/end }} evaluator so previews match real sends. Verified both branches render with zero unfilled tokens. 4. cross-cron double-send: pw-hc-campaign (warmup file) and pw-hc-nppes (63k file) share state but tracked imports per-segment; 312 emails overlap both files, so a provider could get an urgent email from one cron AND the free check from the other. Added load_all_imported() global guard (union of all segment state) so each provider gets exactly one healthcare email overall. All verified: assignment regression test (10 cases) + new dup-email/guard checks pass; all 6 templates render clean. --- data/hc_campaigns/hc_nppes_outdated.html | 7 ++- scripts/build_healthcare_campaigns.py | 29 ++++++++++ scripts/build_healthcare_campaigns_cron.py | 64 ++++++++++++++++++++-- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/data/hc_campaigns/hc_nppes_outdated.html b/data/hc_campaigns/hc_nppes_outdated.html index d7643ef..533be90 100644 --- a/data/hc_campaigns/hc_nppes_outdated.html +++ b/data/hc_campaigns/hc_nppes_outdated.html @@ -14,7 +14,7 @@

Hi {{ .Subscriber.Name }},

We pulled the public records for NPI {{ .Subscriber.Attribs.npi }} — here’s a free check

-

As a quick example, the public NPPES NPI Registry shows the record for {{ .Subscriber.Attribs.practice }} was last updated on {{ .Subscriber.Attribs.nppes_last_updated }} — about {{ .Subscriber.Attribs.nppes_years_stale }} years ago. That’s usually fine, but it’s only one of several things payers and CMS check. Our free tool runs your NPI against the public government sources in one place — no signup, no cost — and tells you exactly where you stand.

+

{{ if .Subscriber.Attribs.nppes_last_updated }}As a quick example, the public NPPES NPI Registry shows the record for {{ .Subscriber.Attribs.practice }} was last updated on {{ .Subscriber.Attribs.nppes_last_updated }} — about {{ .Subscriber.Attribs.nppes_years_stale }} years ago. That’s usually fine, but it’s only one of several things payers and CMS check. {{ else }}Your NPI touches several public government records — NPPES, the Medicare revalidation list, and the federal exclusion lists — and any one of them being off can hold up your payments. {{ end }}Our free tool runs your NPI against those public sources in one place — no signup, no cost — and tells you exactly where you stand.

Your free check covers:

@@ -31,7 +31,9 @@
Payers, clearinghouses, and CMS pull from NPPES. A stale address, taxonomy, or contact can cause claim denials, mail you never receive, and failed credentialing. CMS requires you to correct your NPPES record within 30 days of any change.
- + + {{ if .Subscriber.Attribs.nppes_last_updated }}
@@ -49,6 +51,7 @@
+ {{ end }}
diff --git a/scripts/build_healthcare_campaigns.py b/scripts/build_healthcare_campaigns.py index 530954b..39bfae0 100644 --- a/scripts/build_healthcare_campaigns.py +++ b/scripts/build_healthcare_campaigns.py @@ -140,6 +140,32 @@ def template_path(seg_key: str) -> str: return os.path.join(OUT_DIR, SEGMENTS[seg_key]["template"]) +def _eval_conditionals(html: str, attribs: dict) -> str: + """Minimal evaluator for the listmonk/Go `{{ if .Subscriber.Attribs.X }}A + {{ else }}B{{ end }}` blocks used in the templates, so TEST/PREVIEW renders + match what listmonk produces at send time (listmonk itself evaluates these + server-side; this is only for the standalone preview/test-send path). Treats + an attribute as truthy when it is present and non-empty. Supports an optional + {{ else }} and is non-nested (which is all the templates use).""" + import re + pat = re.compile( + r"\{\{\s*if\s+\.Subscriber\.Attribs\.(\w+)\s*\}\}(.*?)" + r"(?:\{\{\s*else\s*\}\}(.*?))?\{\{\s*end\s*\}\}", + re.DOTALL, + ) + + def repl(m: "re.Match") -> str: + key, if_body, else_body = m.group(1), m.group(2), m.group(3) or "" + return if_body if str(attribs.get(key, "")).strip() else else_body + + # Loop until stable so adjacent/multiple blocks all resolve. + prev = None + while prev != html: + prev = html + html = pat.sub(repl, html) + return html + + def render(seg_key: str, *, test: bool = False) -> tuple[str, str]: """Return (subject, html) for a segment. The html is the canonical data/hc_campaigns/