Two of our three real paid customers (Mark Adams / mark@adamslumber.com and Paul Wilson / synthetic@pipeline.com) never completed intake. They each hit the old hard cap of 10 daily reminders (last sent Jun 12 / Jun 13) and the worker then went permanently silent -- the last two daily runs reminded 0 orders even though both still owe us intake on paid work. (The third, mitchell allen / mitchell@allenscrapmetal.com, did complete intake; his orders are dispatched.) Replace the dead-stop cap with a two-phase cadence: - daily for the first DAILY_PHASE (10) nudges -- the initial burst, - then weekly (WEEKLY_INTERVAL_DAYS) up to an absolute MAX_REMINDERS (60), so a paid order with missing intake keeps getting a gentle nudge instead of being dropped. Tunable via INTAKE_REMINDER_DAILY_PHASE / INTAKE_REMINDER_WEEKLY_INTERVAL_DAYS / INTAKE_REMINDER_MAX. Clearing intake_reminder_last_at re-arms an order immediately (documented in the module docstring).
311 lines
14 KiB
Python
311 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Daily intake-reminder worker.
|
|
|
|
After a customer pays for a compliance service we email them an intake form
|
|
link so we can collect the information needed to prepare the filing. Some
|
|
customers never complete intake, which stalls fulfillment of an order we've
|
|
already been paid for. This worker runs once a day (noon ET) and nudges any
|
|
PAID order whose intake is still incomplete.
|
|
|
|
Cadence (two phases, so we never permanently abandon a paid order):
|
|
1. DAILY phase — the first DAILY_PHASE reminders go out at most once per
|
|
calendar day. This is the initial burst right after payment.
|
|
2. WEEKLY phase — after the daily burst the customer keeps getting a gentle
|
|
nudge, but only once every WEEKLY_INTERVAL_DAYS, until either intake is
|
|
completed or we hit the absolute MAX_REMINDERS ceiling (~a year of
|
|
weekly nudges) at which point ops takes over manually.
|
|
|
|
Previously the worker stopped dead at a 10-reminder cap, so a customer who
|
|
ignored the first burst was silently dropped forever and their paid order
|
|
just stalled. The weekly fallback keeps fulfillment moving.
|
|
|
|
Eligibility (a row is reminded when ALL hold):
|
|
- payment_status = 'paid'
|
|
- intake_data_validated IS NOT TRUE (intake not yet completed)
|
|
- intake_reminder_count < MAX_REMINDERS (absolute ceiling)
|
|
- paid_at is set and older than MIN_AGE_HOURS
|
|
- due for its phase: daily (< today) or weekly (< now - WEEKLY_INTERVAL_DAYS)
|
|
- customer_email is a real, deliverable address (placeholders skipped)
|
|
|
|
The intake link mirrors the one the post-payment email uses:
|
|
{SITE}/order/{service_slug}?order={order_number}
|
|
|
|
Tracking columns (migration 087):
|
|
intake_reminder_count incremented on every send (cap MAX_REMINDERS)
|
|
intake_reminder_last_at set to now() on every send (cadence gate)
|
|
|
|
To re-engage a previously-capped order, clear intake_reminder_last_at (set
|
|
NULL); the next run picks it up regardless of phase.
|
|
|
|
Cron: daily at noon America/New_York. systemd OnCalendar runs in UTC, so the
|
|
timer fires at 16:00 (EST) / 17:00 (EDT); see the worker-crons role. The
|
|
DST-shifting hour is acceptable for a daily nudge.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import smtplib
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
LOG = logging.getLogger("workers.intake_reminder")
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
)
|
|
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pw:pw@localhost:5432/performancewest")
|
|
DOMAIN = os.getenv("DOMAIN", "performancewest.net")
|
|
SITE = f"https://{DOMAIN}"
|
|
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USER = os.getenv("SMTP_USER", "noreply@performancewest.net")
|
|
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
|
SMTP_FROM = os.getenv("SMTP_FROM", "Performance West <noreply@performancewest.net>")
|
|
|
|
# Absolute per-order ceiling. With the weekly fallback below this is ~a year of
|
|
# nudges before we give up and leave it to ops. Generous on purpose: a paid
|
|
# order with no intake is money we owe work on, so we keep gently reaching out.
|
|
MAX_REMINDERS = int(os.getenv("INTAKE_REMINDER_MAX", "60"))
|
|
|
|
# How many of the first reminders go out on the daily cadence (the initial
|
|
# burst right after payment). After this many, we fall back to WEEKLY so the
|
|
# customer keeps hearing from us without being nagged every single day.
|
|
DAILY_PHASE = int(os.getenv("INTAKE_REMINDER_DAILY_PHASE", "10"))
|
|
|
|
# Weekly-phase spacing (days) once the daily burst is exhausted.
|
|
WEEKLY_INTERVAL_DAYS = int(os.getenv("INTAKE_REMINDER_WEEKLY_INTERVAL_DAYS", "7"))
|
|
|
|
# Only remind once payment has settled long enough for the post-payment intake
|
|
# email to have been delivered first (avoids double-emailing within the hour).
|
|
MIN_AGE_HOURS = int(os.getenv("INTAKE_REMINDER_MIN_AGE_HOURS", "20"))
|
|
|
|
# Mirror the API's email validation (api/src/routes/compliance-orders.ts):
|
|
# reject malformed addresses AND RFC-reserved non-deliverable test domains.
|
|
# NOTE: pipeline.com is a REAL (EarthLink) domain a customer uses -- not a
|
|
# placeholder -- so it is NOT blocked.
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
PLACEHOLDER_DOMAINS = {"example.com", "test.com", "invalid"}
|
|
|
|
|
|
def _email_ok(raw: str | None) -> bool:
|
|
email = (raw or "").strip().lower()
|
|
if not email or not EMAIL_RE.match(email):
|
|
return False
|
|
domain = email.split("@", 1)[1] if "@" in email else ""
|
|
if domain in PLACEHOLDER_DOMAINS:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _send_email(to: str, subject: str, html: str) -> bool:
|
|
try:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["From"] = SMTP_FROM
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.attach(MIMEText(html, "html"))
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
|
|
s.starttls()
|
|
s.login(SMTP_USER, SMTP_PASS)
|
|
s.send_message(msg)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
LOG.warning("Email send failed to %s: %s", to, exc)
|
|
return False
|
|
|
|
|
|
def _build_html(customer_name: str, services: list[dict], nth: int) -> str:
|
|
"""Branded reminder listing every incomplete service for this customer."""
|
|
greeting = customer_name.strip() or "there"
|
|
items = "\n".join(
|
|
f'<li style="margin:8px 0;">'
|
|
f'<a href="{s["url"]}" style="color:#1e40af;font-weight:600;font-size:14px;'
|
|
f'text-decoration:underline;">{s["name"]}</a></li>'
|
|
for s in services
|
|
)
|
|
# Tone escalates across the cadence but always stays friendly. `nth` is the
|
|
# reminder number about to be sent (1-indexed): the daily burst is 1..DAILY_PHASE,
|
|
# the weekly fallback is DAILY_PHASE+1..MAX_REMINDERS.
|
|
if nth <= 1:
|
|
lede = (
|
|
"Thanks for your order! To start preparing your filing we just need "
|
|
"you to complete a short intake form for each service below."
|
|
)
|
|
elif nth >= MAX_REMINDERS:
|
|
lede = (
|
|
"This is our final automated reminder. Your filing is on hold until "
|
|
"we receive your intake details. Please complete the form(s) below, "
|
|
"or reply to this email and we'll be glad to help."
|
|
)
|
|
elif nth > DAILY_PHASE:
|
|
lede = (
|
|
"We're still holding your paid order open and ready to file — we just "
|
|
"need your intake details to proceed. It only takes a couple of "
|
|
"minutes. Please complete the form(s) below, or reply to this email "
|
|
"and we'll be glad to help."
|
|
)
|
|
else:
|
|
lede = (
|
|
"We're still waiting on your intake details before we can prepare "
|
|
"your filing. It only takes a couple of minutes — please complete "
|
|
"the form(s) below."
|
|
)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f7f7f7;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:32px 16px;">
|
|
<tr><td align="center">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#fff;border-radius:12px;border:1px solid #e5e5e5;overflow:hidden;">
|
|
<tr><td style="background:#1e3a5f;padding:24px 32px;text-align:center;">
|
|
<h1 style="margin:0;color:#fff;font-size:20px;font-weight:700;">Performance West</h1>
|
|
</td></tr>
|
|
<tr><td style="padding:32px;">
|
|
<p style="margin:0 0 16px;font-size:15px;color:#333;">Hi {greeting},</p>
|
|
<p style="margin:0 0 20px;font-size:14px;color:#555;line-height:1.6;">{lede}</p>
|
|
<div style="background:#eff6ff;border:2px solid #3b82f6;border-radius:8px;padding:20px;margin:0 0 24px;">
|
|
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#1e3a5f;">Action Required: Complete Your Intake Form</p>
|
|
<ul style="margin:0;padding-left:18px;">{items}</ul>
|
|
</div>
|
|
<p style="margin:0;font-size:13px;color:#666;line-height:1.6;">
|
|
Questions? Reply to this email, contact
|
|
<a href="mailto:info@performancewest.net" style="color:#1e40af;">info@performancewest.net</a>,
|
|
or call <a href="tel:+18884110383" style="color:#1e40af;">1-888-411-0383</a>.
|
|
</p>
|
|
</td></tr>
|
|
<tr><td style="background:#f9fafb;padding:16px 32px;text-align:center;border-top:1px solid #e5e7eb;">
|
|
<p style="margin:0;font-size:11px;color:#9ca3af;">Performance West Inc. · performancewest.net · 1-888-411-0383</p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body></html>"""
|
|
|
|
|
|
def run() -> int:
|
|
conn = psycopg2.connect(DATABASE_URL)
|
|
conn.autocommit = False
|
|
sent_orders = 0
|
|
skipped_placeholder = set()
|
|
try:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
# All paid + intake-incomplete orders still under the absolute cap,
|
|
# aged past MIN_AGE_HOURS, that are DUE for their cadence phase:
|
|
# - daily phase (count < DAILY_PHASE): due if not reminded today.
|
|
# - weekly phase (count >= DAILY_PHASE): due if last reminder was
|
|
# more than WEEKLY_INTERVAL_DAYS ago.
|
|
# A NULL last-reminded is always due (covers brand-new orders AND
|
|
# any order an operator manually re-armed by clearing the column).
|
|
cur.execute(
|
|
"""
|
|
SELECT order_number, service_slug, service_name,
|
|
customer_email, customer_name,
|
|
intake_reminder_count
|
|
FROM compliance_orders
|
|
WHERE payment_status = 'paid'
|
|
AND COALESCE(intake_data_validated, FALSE) = FALSE
|
|
AND intake_reminder_count < %(max_reminders)s
|
|
AND paid_at IS NOT NULL
|
|
AND paid_at < now() - (%(min_age_hours)s || ' hours')::interval
|
|
AND (
|
|
intake_reminder_last_at IS NULL
|
|
OR (
|
|
intake_reminder_count < %(daily_phase)s
|
|
AND intake_reminder_last_at < date_trunc('day', now())
|
|
)
|
|
OR (
|
|
intake_reminder_count >= %(daily_phase)s
|
|
AND intake_reminder_last_at
|
|
< now() - (%(weekly_days)s || ' days')::interval
|
|
)
|
|
)
|
|
ORDER BY customer_email, order_number
|
|
""",
|
|
{
|
|
"max_reminders": MAX_REMINDERS,
|
|
"min_age_hours": str(MIN_AGE_HOURS),
|
|
"daily_phase": DAILY_PHASE,
|
|
"weekly_days": str(WEEKLY_INTERVAL_DAYS),
|
|
},
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
# Group eligible orders by customer email so a customer with several
|
|
# incomplete services gets one consolidated email, not one per order.
|
|
by_email: dict[str, list[dict]] = {}
|
|
for r in rows:
|
|
email = (r["customer_email"] or "").strip().lower()
|
|
if not _email_ok(email):
|
|
skipped_placeholder.add(email or "<empty>")
|
|
continue
|
|
by_email.setdefault(email, []).append(r)
|
|
|
|
for email, orders in by_email.items():
|
|
name = next((o["customer_name"] for o in orders if o["customer_name"]), "")
|
|
# nth = the lowest reminder count among this customer's orders + 1
|
|
nth = min(o["intake_reminder_count"] for o in orders) + 1
|
|
services = [
|
|
{
|
|
"name": o["service_name"] or o["service_slug"],
|
|
"url": f"{SITE}/order/{o['service_slug']}?order={o['order_number']}",
|
|
}
|
|
for o in orders
|
|
]
|
|
subject = (
|
|
"Action needed: complete your intake form"
|
|
if nth <= 1
|
|
else "Reminder: your filing is waiting on your intake details"
|
|
)
|
|
if nth >= MAX_REMINDERS:
|
|
subject = "Final reminder: complete your intake form"
|
|
|
|
html = _build_html(name, services, nth)
|
|
if not _send_email(email, subject, html):
|
|
LOG.warning("Skipping reminder bookkeeping for %s (send failed)", email)
|
|
continue
|
|
|
|
# Mark every order we just reminded (one DB round-trip).
|
|
order_numbers = [o["order_number"] for o in orders]
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE compliance_orders
|
|
SET intake_reminder_count = intake_reminder_count + 1,
|
|
intake_reminder_last_at = now()
|
|
WHERE order_number = ANY(%s)
|
|
""",
|
|
(order_numbers,),
|
|
)
|
|
conn.commit()
|
|
sent_orders += len(order_numbers)
|
|
LOG.info(
|
|
"Reminded %s (nth=%s) for %s order(s): %s",
|
|
email, nth, len(order_numbers), ", ".join(order_numbers),
|
|
)
|
|
|
|
if skipped_placeholder:
|
|
LOG.info(
|
|
"Skipped %s placeholder/invalid email(s): %s",
|
|
len(skipped_placeholder), ", ".join(sorted(skipped_placeholder)),
|
|
)
|
|
LOG.info("Done. Reminded %s order(s) across %s customer(s).", sent_orders, len(by_email))
|
|
return 0
|
|
except Exception: # noqa: BLE001
|
|
conn.rollback()
|
|
LOG.exception("intake_reminder run failed")
|
|
return 1
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run())
|