hc campaigns: make the HTML templates the single source of truth
build_healthcare_campaigns.py had a divergent inline HTML generator (old teal- header + yellow issue-box layout, missing the official-record card and the per- segment verify-it-yourself blocks) that nobody called -- the live cron reads the hand-tuned data/hc_campaigns/hc_*.html files directly. Removed the dead generator + cmd_render(); render() now READS the canonical template file so the files can't drift from a parallel generator. SEGMENTS is now a metadata registry (subject, template, cta_path, price, list_name, campaign_name, selector) that the multi-segment cron will consume. Verified --list and that send-test still reads the real bodies.
This commit is contained in:
parent
aa195e6c18
commit
0b0ff9d311
1 changed files with 64 additions and 191 deletions
|
|
@ -1,28 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Healthcare (NPI/Medicare) marketing emails, segmented by the compliance
|
||||
problem that needs correcting. Mirrors the trucking brand shell
|
||||
(scripts/campaign_template.html) so all PW outbound looks consistent.
|
||||
"""Healthcare (NPI/Medicare) marketing-email SEGMENT REGISTRY + test tooling.
|
||||
|
||||
Each segment maps to a real PW service + order page:
|
||||
SINGLE SOURCE OF TRUTH for the healthcare campaign segments. Each segment maps a
|
||||
compliance problem to a real PW service, its order page, price, the listmonk
|
||||
list/campaign it warms, and the canonical HTML template under data/hc_campaigns/.
|
||||
|
||||
revalidation_overdue Medicare PECOS Revalidation Filing ($599) /order/npi-revalidation
|
||||
npi_reactivation NPI Reactivation ($449) /order/npi-reactivation
|
||||
nppes_outdated NPPES Data Update / Attestation ($349) /order/nppes-update
|
||||
oig_screening OIG/SAM Exclusion Screening ($299) /order/oig-sam-screening
|
||||
compliance_bundle Provider Compliance Bundle (annual) ($899) /order/provider-compliance-bundle
|
||||
The HTML bodies themselves are the hand-tuned, deployed templates in
|
||||
data/hc_campaigns/hc_<seg>.html (teal header, per-segment "verify it yourself"
|
||||
trust block, official-record card on revalidation, etc.). This module does NOT
|
||||
regenerate them -- it READS them, so the files stay the one source of truth and
|
||||
can't drift from a parallel generator. (An earlier version of this script kept a
|
||||
divergent inline generator; that was removed.)
|
||||
|
||||
Two uses:
|
||||
* `--render <segment>` -> writes the listmonk campaign HTML to out/.
|
||||
* `--send-test <email>` -> sends every segment as a real test through the
|
||||
healthcare HOT SMTP stream (host :2526 -> hcout1 -> .107), so you see exactly
|
||||
Consumers:
|
||||
* build_healthcare_campaigns_cron.py imports SEGMENTS to warm every segment.
|
||||
* `--send-test <email>` sends every segment as a real test through the
|
||||
healthcare HOT SMTP stream (host :2526 -> hcout1 -> .107) so you see exactly
|
||||
what a provider receives. Personalization tokens are filled with sample data.
|
||||
|
||||
Listmonk personalization tokens used (kept identical to trucking so the same
|
||||
subscriber-attribs convention applies on real sends):
|
||||
Listmonk personalization tokens used on real sends (filled from subscriber
|
||||
attribs by listmonk; filled from SAMPLE here for test sends):
|
||||
{{ .Subscriber.Name }} provider / practice name
|
||||
{{ .Subscriber.Attribs.npi }} NPI
|
||||
{{ .Subscriber.Attribs.practice }} practice / org name
|
||||
{{ .Subscriber.Attribs.detail }} segment-specific detail (e.g. due date)
|
||||
{{ .Subscriber.Attribs.reval_due_date }} / .days_overdue (revalidation card)
|
||||
{{ UnsubscribeURL }} listmonk per-subscriber unsubscribe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -38,105 +40,60 @@ FROM_EMAIL = "compliance@performancewest.net"
|
|||
REPLY_TO = "info@performancewest.net"
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "hc_campaigns")
|
||||
|
||||
# ── Per-segment content ────────────────────────────────────────────────────
|
||||
# ── Per-segment registry ───────────────────────────────────────────────────
|
||||
# Metadata only. The email body lives in OUT_DIR/<template>. Fields:
|
||||
# subject listmonk campaign subject line
|
||||
# template HTML file under data/hc_campaigns/ (the canonical body)
|
||||
# cta_path order page the CTA links to (NPI appended as ?npi=)
|
||||
# price headline price (for reference / docs; lives in the template)
|
||||
# list_name listmonk-hc list this segment is warmed into
|
||||
# campaign_name listmonk-hc campaign name prefix (dated per build)
|
||||
# selector which warmup-CSV rows belong to this segment (see cron)
|
||||
SEGMENTS = {
|
||||
"revalidation_overdue": {
|
||||
"subject": "Action needed: your Medicare revalidation is overdue",
|
||||
"alert": "Medicare Revalidation Alert",
|
||||
"subhead": "Your CMS revalidation deadline has passed",
|
||||
"headline": "Your Medicare billing privileges are at risk",
|
||||
"lede": ("CMS records indicate the Medicare enrollment revalidation for "
|
||||
"<strong>{{ .Subscriber.Attribs.practice }}</strong> "
|
||||
"(NPI {{ .Subscriber.Attribs.npi }}) is <strong>past due</strong>."),
|
||||
"issue_title": "What this means",
|
||||
"issue_html": ("If you do not revalidate, CMS will <strong>deactivate your "
|
||||
"Medicare billing privileges</strong> — claims stop paying "
|
||||
"and you must re-enroll from scratch, losing your effective date "
|
||||
"and any retroactive billing."),
|
||||
"detail_label": "Revalidation due",
|
||||
"cta_copy": "We file your PECOS revalidation for you, before the clock runs out.",
|
||||
"cta_sub": "Most filings submitted within 1-2 business days.",
|
||||
"cta_label": "Start my revalidation \u2192",
|
||||
"template": "hc_revalidation_overdue.html",
|
||||
"cta_path": "/order/npi-revalidation",
|
||||
"price": "$599",
|
||||
"list_name": "HC Warmup - Revalidation Overdue",
|
||||
"campaign_name": "HC Warmup - Medicare Revalidation",
|
||||
"selector": "reval_overdue",
|
||||
},
|
||||
"npi_reactivation": {
|
||||
"subject": "Your NPI / Medicare enrollment appears deactivated",
|
||||
"alert": "Provider Enrollment Alert",
|
||||
"subhead": "Deactivated enrollment detected",
|
||||
"headline": "Your enrollment looks deactivated \u2014 let's fix it fast",
|
||||
"lede": ("Our compliance monitoring flagged the enrollment for "
|
||||
"<strong>{{ .Subscriber.Attribs.practice }}</strong> "
|
||||
"(NPI {{ .Subscriber.Attribs.npi }}) as <strong>deactivated or inactive</strong>."),
|
||||
"issue_title": "Why it matters",
|
||||
"issue_html": ("A deactivated enrollment means Medicare claims are being "
|
||||
"<strong>rejected</strong>. Reactivation restores your billing "
|
||||
"privileges — the sooner it's filed, the less revenue you lose."),
|
||||
"detail_label": "Status",
|
||||
"cta_copy": "We handle the CMS-855 reactivation end to end.",
|
||||
"cta_sub": "We verify every field against current CMS requirements.",
|
||||
"cta_label": "Reactivate my enrollment \u2192",
|
||||
"template": "hc_npi_reactivation.html",
|
||||
"cta_path": "/order/npi-reactivation",
|
||||
"price": "$449",
|
||||
"list_name": "HC Warmup - Reactivation",
|
||||
"campaign_name": "HC Warmup - NPI Reactivation",
|
||||
"selector": "leie_or_deactivated",
|
||||
},
|
||||
"nppes_outdated": {
|
||||
"subject": "Your NPPES record may be out of date",
|
||||
"alert": "NPPES Data Alert",
|
||||
"subhead": "Outdated registry information detected",
|
||||
"headline": "Outdated NPPES data can hold up your payments",
|
||||
"lede": ("The public NPPES registry record for "
|
||||
"<strong>{{ .Subscriber.Attribs.practice }}</strong> "
|
||||
"(NPI {{ .Subscriber.Attribs.npi }}) appears <strong>out of date</strong>."),
|
||||
"issue_title": "Why it matters",
|
||||
"issue_html": ("Payers, clearinghouses, and CMS pull from NPPES. A stale "
|
||||
"address, taxonomy, or contact can cause <strong>claim denials, "
|
||||
"mail you never receive, and failed credentialing</strong>. CMS "
|
||||
"also requires you to attest your NPPES data periodically."),
|
||||
"detail_label": "Record",
|
||||
"cta_copy": "We update and attest your NPPES record for you.",
|
||||
"cta_sub": "Address, taxonomy, contacts, and authorized official.",
|
||||
"cta_label": "Update my NPPES record \u2192",
|
||||
"template": "hc_nppes_outdated.html",
|
||||
"cta_path": "/order/nppes-update",
|
||||
"price": "$349",
|
||||
"list_name": "HC Warmup - NPPES Update",
|
||||
"campaign_name": "HC Warmup - NPPES Outdated",
|
||||
"selector": "reval_upcoming",
|
||||
},
|
||||
"oig_screening": {
|
||||
"subject": "Are you screening for OIG / SAM exclusions?",
|
||||
"alert": "Exclusion Screening Notice",
|
||||
"subhead": "Annual OIG/SAM screening requirement",
|
||||
"headline": "One excluded individual can cost you everything",
|
||||
"lede": ("Federal rules require practices that bill Medicare/Medicaid to "
|
||||
"screen employees and vendors against the <strong>OIG LEIE</strong> and "
|
||||
"<strong>SAM</strong> exclusion lists \u2014 and to document it."),
|
||||
"issue_title": "Why it matters",
|
||||
"issue_html": ("Employing or contracting an excluded party triggers <strong>civil "
|
||||
"monetary penalties up to $20,000 per claim</strong> plus repayment. "
|
||||
"Most practices have <strong>no documented screening process</strong>."),
|
||||
"detail_label": "Practice",
|
||||
"cta_copy": "We run and document your OIG/SAM exclusion screening.",
|
||||
"cta_sub": "Monthly checks with an audit-ready record.",
|
||||
"cta_label": "Set up exclusion screening \u2192",
|
||||
"template": "hc_oig_screening.html",
|
||||
"cta_path": "/order/oig-sam-screening",
|
||||
"price": "$299",
|
||||
"list_name": "HC Warmup - OIG Screening",
|
||||
"campaign_name": "HC Warmup - OIG Screening",
|
||||
"selector": "any",
|
||||
},
|
||||
"compliance_bundle": {
|
||||
"subject": "Get your provider compliance handled for the year",
|
||||
"alert": "Provider Compliance Review",
|
||||
"subhead": "Annual compliance, done for you",
|
||||
"headline": "Stop chasing deadlines \u2014 we'll handle the whole year",
|
||||
"lede": ("Between revalidation, NPPES attestation, and exclusion screening, "
|
||||
"provider compliance is a moving target for "
|
||||
"<strong>{{ .Subscriber.Attribs.practice }}</strong> "
|
||||
"(NPI {{ .Subscriber.Attribs.npi }})."),
|
||||
"issue_title": "What's included",
|
||||
"issue_html": ("Revalidation monitoring & filing, NPPES updates/attestation, "
|
||||
"and monthly OIG/SAM exclusion screening — one flat annual "
|
||||
"price, all tracked, all documented."),
|
||||
"detail_label": "Practice",
|
||||
"cta_copy": "One annual bundle covers your core CMS obligations.",
|
||||
"cta_sub": "We watch the deadlines so you never miss one.",
|
||||
"cta_label": "Get the compliance bundle \u2192",
|
||||
"template": "hc_compliance_bundle.html",
|
||||
"cta_path": "/order/provider-compliance-bundle",
|
||||
"price": "$899/yr",
|
||||
"list_name": "HC Warmup - Compliance Bundle",
|
||||
"campaign_name": "HC Warmup - Compliance Bundle",
|
||||
"selector": "optout_ending",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -146,119 +103,34 @@ SAMPLE = {
|
|||
"practice": "Riverbend Family Medicine",
|
||||
"npi": "1234567890",
|
||||
"detail": "06/30/2024 (706 days overdue)",
|
||||
"reval_due_date": "06/30/2024",
|
||||
"days_overdue": "706",
|
||||
}
|
||||
|
||||
|
||||
def template_path(seg_key: str) -> str:
|
||||
return os.path.join(OUT_DIR, SEGMENTS[seg_key]["template"])
|
||||
|
||||
|
||||
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/<template> file -- the single source of truth. For test
|
||||
sends, listmonk tokens are filled with SAMPLE data so the email is viewable
|
||||
standalone."""
|
||||
s = SEGMENTS[seg_key]
|
||||
cta_url = f"{SITE}{s['cta_path']}?npi={{{{ .Subscriber.Attribs.npi }}}}"
|
||||
html = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>@media only screen and (max-width:600px){{.pw-wrap{{width:100%!important;border-radius:0!important;}}.pw-pad{{padding:24px 16px!important;}}}}body,table,td,p,a{{-webkit-text-size-adjust:100%;}}table{{border-collapse:collapse!important;}}img{{border:0;}}</style></head><body style="margin:0;padding:0;background:#eef0f3;">
|
||||
<center>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;"><tr><td style="padding:24px 10px;">
|
||||
<table role="presentation" class="pw-wrap" width="620" cellpadding="0" cellspacing="0" style="margin:0 auto;border-radius:10px;overflow:hidden;background:#fff;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr><td style="background-color:#0f766e;background:linear-gradient(135deg,#0f766e 0%,#14b8a6 100%);padding:26px 28px;">
|
||||
<img src="{SITE}/images/logo-white.png" alt="Performance West" style="height:44px;margin-bottom:10px;display:block" />
|
||||
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:700;font-family:Inter,system-ui,sans-serif;">{s['alert']}</h1>
|
||||
<p style="color:#ccfbf1;margin:6px 0 0;font-size:13px;font-family:Inter,system-ui,sans-serif;">{s['subhead']}</p>
|
||||
</td></tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr><td class="pw-pad" style="padding:28px;font-family:Inter,system-ui,sans-serif;color:#1f2937;">
|
||||
<p style="font-size:15px;margin:0 0 18px;line-height:1.5;">Hi {{{{ .Subscriber.Name }}}},</p>
|
||||
<h2 style="font-size:19px;margin:0 0 14px;color:#0f172a;line-height:1.3;">{s['headline']}</h2>
|
||||
<p style="font-size:14px;line-height:1.7;margin:0 0 18px;">{s['lede']}</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#fffbeb;border:2px solid #fcd34d;border-radius:10px;padding:18px;">
|
||||
<h3 style="margin:0 0 10px;font-size:15px;color:#92400e;font-weight:700;">⚠️ {s['issue_title']}</h3>
|
||||
<div style="font-size:13px;color:#7c2d12;line-height:1.7;">{s['issue_html']}</div>
|
||||
</td></tr></table>
|
||||
|
||||
<!-- Detail row -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0;font-size:13px;">
|
||||
<tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">NPI</td><td style="padding:10px 0;font-weight:600;text-align:right;color:#1f2937;">{{{{ .Subscriber.Attribs.npi }}}}</td></tr>
|
||||
<tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">{s['detail_label']}</td><td style="padding:10px 0;font-weight:600;text-align:right;color:#b91c1c;">{{{{ .Subscriber.Attribs.detail }}}}</td></tr>
|
||||
<tr><td style="padding:10px 0;color:#6b7280;">Our service fee</td><td style="padding:10px 0;font-weight:700;text-align:right;color:#047857;">{s['price']}</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- No-hassle value (sell the relief, not the mechanics) -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#f0fdfa;border:1px solid #99f6e4;border-radius:10px;padding:18px;">
|
||||
<p style="font-size:14px;color:#0f766e;margin:0 0 10px;font-weight:700;">No 2FA. No government portals. No headaches.</p>
|
||||
<p style="font-size:13px;color:#134e4a;line-height:1.7;margin:0;">
|
||||
You do <strong>not</strong> need to remember a PECOS password, pass CMS identity
|
||||
verification or two-factor codes, fight a government website, or sit on hold with
|
||||
Medicare. We do the work for you — all you have to do is say yes. We
|
||||
<strong>verify every detail for accuracy</strong> so the filing isn’t
|
||||
rejected, keep it on track, and confirm when it’s accepted.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#134e4a;line-height:1.7;margin:12px 0 0;border-top:1px solid #ccfbf1;padding-top:12px;">
|
||||
<strong>About Performance West.</strong> We’re a regulatory compliance
|
||||
consulting firm that handles government filings for businesses across
|
||||
healthcare, telecom, and transportation. Our healthcare team specializes in
|
||||
Medicare and provider compliance — revalidation, enrollment, NPI/NPPES,
|
||||
and exclusion screening — so practices stay compliant and keep getting
|
||||
paid, without the administrative burden.
|
||||
</p>
|
||||
</td></tr></table>
|
||||
|
||||
<!-- CTA -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#ecfdf5;border:2px solid #10b981;border-radius:10px;padding:18px;text-align:center;">
|
||||
<p style="font-size:14px;color:#065f46;margin:0 0 6px;font-weight:600;">{s['cta_copy']}</p>
|
||||
<p style="font-size:12px;color:#047857;margin:0 0 14px;">{s['cta_sub']}</p>
|
||||
<a href="{cta_url}" style="display:inline-block;padding:14px 40px;background:#10b981;color:#fff;font-weight:700;border-radius:8px;text-decoration:none;font-size:15px;">{s['cta_label']}</a>
|
||||
</td></tr></table>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0;"><tr><td style="background:#f0f4f8;border-radius:8px;padding:16px;font-size:13px;color:#374151;line-height:1.6;">
|
||||
<strong>Questions?</strong> Reply to this email or call <strong>{PHONE}</strong>. Performance West is a dedicated healthcare compliance firm — we handle the CMS/NPPES paperwork so you can focus on patients.
|
||||
</td></tr></table>
|
||||
|
||||
<!-- Trust signals (data-safety + guarantee — relevant when sharing NPI/PECOS info) -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0 6px;border-top:1px solid #e5e7eb;padding-top:14px;">
|
||||
<tr>
|
||||
<td align="center" style="padding:10px 4px;font-family:Inter,system-ui,sans-serif;">
|
||||
<span style="display:inline-block;margin:0 8px;font-size:11px;font-weight:600;color:#0f766e;">🛡️ SOC 2 Type II hosting</span>
|
||||
<span style="display:inline-block;margin:0 8px;font-size:11px;font-weight:600;color:#0f766e;">✅ HIPAA & PCI compliant</span>
|
||||
<span style="display:inline-block;margin:0 8px;font-size:11px;font-weight:600;color:#0f766e;">🔒 256-bit TLS encrypted</span>
|
||||
<span style="display:inline-block;margin:0 8px;font-size:11px;font-weight:600;color:#0f766e;">💳 Secure payment by Stripe</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:4px;font-family:Inter,system-ui,sans-serif;font-size:12px;color:#4b5563;">
|
||||
<strong style="color:#047857;">100% satisfaction guarantee</strong> · fixed pricing, no billable hours · trusted by providers nationwide
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding:16px 28px;background:#f8fafc;border-top:1px solid #e5e7eb;font-size:11px;color:#9ca3af;text-align:center;">
|
||||
<p style="margin:0;">Performance West Inc. · Cheyenne, WY · <a href="{SITE}" style="color:#6b7280;">performancewest.net</a></p>
|
||||
<p style="margin:6px 0 0;"><a href="{{{{ UnsubscribeURL }}}}" style="color:#6b7280;">Unsubscribe</a></p>
|
||||
</td></tr>
|
||||
|
||||
</table></td></tr></table></center></body></html>"""
|
||||
|
||||
html = open(template_path(seg_key)).read()
|
||||
if test:
|
||||
# Fill listmonk tokens with sample data for a standalone test send.
|
||||
html = (html
|
||||
.replace("{{ .Subscriber.Name }}", SAMPLE["name"])
|
||||
.replace("{{ .Subscriber.Attribs.npi }}", SAMPLE["npi"])
|
||||
.replace("{{ .Subscriber.Attribs.practice }}", SAMPLE["practice"])
|
||||
.replace("{{ .Subscriber.Attribs.detail }}", SAMPLE["detail"])
|
||||
.replace("{{ .Subscriber.Attribs.reval_due_date }}", SAMPLE["reval_due_date"])
|
||||
.replace("{{ .Subscriber.Attribs.days_overdue }}", SAMPLE["days_overdue"])
|
||||
.replace("{{ UnsubscribeURL }}", f"{SITE}/unsubscribe?test=1"))
|
||||
return s["subject"], html
|
||||
|
||||
|
||||
def cmd_render():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
for key in SEGMENTS:
|
||||
subj, html = render(key)
|
||||
path = os.path.join(OUT_DIR, f"hc_{key}.html")
|
||||
open(path, "w").write(html)
|
||||
print(f" wrote {path} (subject: {subj})")
|
||||
|
||||
|
||||
def cmd_send_test(to_addr: str, host: str, port: int):
|
||||
from email.utils import formatdate
|
||||
n = 0
|
||||
|
|
@ -291,16 +163,17 @@ def cmd_send_test(to_addr: str, host: str, port: int):
|
|||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--render", action="store_true", help="write listmonk HTML to data/hc_campaigns/")
|
||||
ap.add_argument("--send-test", metavar="EMAIL", help="send all segments as test to EMAIL")
|
||||
ap.add_argument("--list", action="store_true", help="list the segment registry")
|
||||
ap.add_argument("--smtp-host", default="127.0.0.1")
|
||||
ap.add_argument("--smtp-port", type=int, default=2526, help="hc HOT submission port")
|
||||
args = ap.parse_args()
|
||||
if args.render:
|
||||
cmd_render()
|
||||
if args.list:
|
||||
for k, s in SEGMENTS.items():
|
||||
print(f"{k:22} {s['price']:>8} {s['template']:30} -> {s['campaign_name']}")
|
||||
if args.send_test:
|
||||
cmd_send_test(args.send_test, args.smtp_host, args.smtp_port)
|
||||
if not args.render and not args.send_test:
|
||||
if not args.send_test and not args.list:
|
||||
ap.print_help()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue