From ad3d189b2b2f3daea6ae6bf801dbe6e7a280e9a8 Mon Sep 17 00:00:00 2001 From: justin Date: Sat, 30 May 2026 21:22:14 -0500 Subject: [PATCH] post-completion flow: survey, referral program, review ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 081: referral_codes, referral_uses, exit_surveys tables - API: POST /api/v1/survey, POST /api/v1/referral/check, GET /api/v1/referral/:email - Worker: completion_emails.py — sends completion + 24h follow-up (survey + referral) - Survey page: /survey/?order=X&rating=N — star rating, feedback, Google review ask - Referral: REF-FIRSTNAME codes, $25 credit per referred order, no limit - Low ratings (1-3 stars) trigger Telegram alert for admin follow-up - Cron: every 15 minutes --- api/migrations/081_referral_and_survey.sql | 47 +++ api/src/index.ts | 2 + api/src/routes/survey.ts | 152 ++++++++++ .../roles/worker-crons/defaults/main.yml | 8 + scripts/workers/completion_emails.py | 274 ++++++++++++++++++ site/public/survey/index.html | 172 +++++++++++ 6 files changed, 655 insertions(+) create mode 100644 api/migrations/081_referral_and_survey.sql create mode 100644 api/src/routes/survey.ts create mode 100644 scripts/workers/completion_emails.py create mode 100644 site/public/survey/index.html diff --git a/api/migrations/081_referral_and_survey.sql b/api/migrations/081_referral_and_survey.sql new file mode 100644 index 0000000..86e4863 --- /dev/null +++ b/api/migrations/081_referral_and_survey.sql @@ -0,0 +1,47 @@ +-- Referral codes and survey responses for post-completion flow + +-- Referral codes (one per customer) +CREATE TABLE IF NOT EXISTS referral_codes ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, -- e.g. REF-MARKADAMS + customer_email TEXT NOT NULL, + customer_name TEXT, + credit_cents INTEGER DEFAULT 2500, -- $25 credit + times_used INTEGER DEFAULT 0, + total_earned_cents INTEGER DEFAULT 0, + balance_cents INTEGER DEFAULT 0, -- unused credit + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_referral_codes_email ON referral_codes(customer_email); +CREATE INDEX IF NOT EXISTS idx_referral_codes_code ON referral_codes(code); + +-- Referral usage tracking +CREATE TABLE IF NOT EXISTS referral_uses ( + id SERIAL PRIMARY KEY, + referral_code TEXT NOT NULL REFERENCES referral_codes(code), + referred_order TEXT NOT NULL, -- order_number of the new customer's order + referred_email TEXT NOT NULL, + credit_cents INTEGER DEFAULT 2500, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Exit survey responses +CREATE TABLE IF NOT EXISTS exit_surveys ( + id SERIAL PRIMARY KEY, + order_number TEXT NOT NULL, + customer_email TEXT NOT NULL, + rating INTEGER CHECK (rating BETWEEN 1 AND 5), + feedback TEXT, + would_recommend BOOLEAN, + review_link_clicked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_exit_surveys_order ON exit_surveys(order_number); + +-- Track which completion emails have been sent (prevent duplicates) +ALTER TABLE compliance_orders + ADD COLUMN IF NOT EXISTS completion_email_sent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS followup_email_sent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS referral_code TEXT; diff --git a/api/src/index.ts b/api/src/index.ts index cadbb84..439b412 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -47,6 +47,7 @@ import portalEsignGenericRouter from "./routes/portal-esign-generic.js"; import pucRouter from "./routes/puc.js"; import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js"; import dotLookupRouter from "./routes/dot-lookup.js"; +import surveyRouter from "./routes/survey.js"; const app = express(); @@ -120,6 +121,7 @@ app.use(foreignQualRouter); app.use(pucRouter); app.use(fccCarrierRegRouter); app.use(dotLookupRouter); +app.use(surveyRouter); app.use(adminCryptoRouter); // Note: identityRouter mounted above express.json() for webhook route, // but also handles non-webhook routes (create-session, poll) which work fine with json() diff --git a/api/src/routes/survey.ts b/api/src/routes/survey.ts new file mode 100644 index 0000000..dcbb1f4 --- /dev/null +++ b/api/src/routes/survey.ts @@ -0,0 +1,152 @@ +/** + * Exit survey + referral endpoints. + * + * POST /api/v1/survey — Submit exit survey response + * GET /api/v1/survey/:order — Get survey form data (pre-fill) + * POST /api/v1/referral/check — Validate a referral code + * GET /api/v1/referral/:email — Get referral code + stats for a customer + */ + +import { Router, type Request, type Response } from "express"; +import { pool } from "../db.js"; + +const router = Router(); + +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ""; +const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID || ""; + +// POST /api/v1/survey — Submit exit survey +router.post("/api/v1/survey", async (req: Request, res: Response) => { + try { + const { order_number, rating, feedback, would_recommend } = req.body ?? {}; + if (!order_number || !rating) { + res.status(400).json({ error: "order_number and rating required." }); + return; + } + + // Get customer email from order + const orderResult = await pool.query( + "SELECT customer_email, customer_name, service_name FROM compliance_orders WHERE order_number = $1", + [order_number], + ); + const order = orderResult.rows[0] as Record | undefined; + const email = (order?.customer_email as string) || ""; + + await pool.query( + `INSERT INTO exit_surveys (order_number, customer_email, rating, feedback, would_recommend) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, + [order_number, email, rating, feedback || null, would_recommend ?? null], + ); + + // Telegram alert for low ratings + if (rating <= 3 && TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID) { + const text = `⚠️ Low survey rating\nOrder: ${order_number}\nRating: ${"⭐".repeat(rating)}\nCustomer: ${email}\nFeedback: ${feedback || "(none)"}`; + fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: TELEGRAM_CHAT_ID, text }), + }).catch(() => {}); + } + + // If high rating, return Google review link + const showReview = rating >= 4; + const googleReviewUrl = process.env.GOOGLE_REVIEW_URL || ""; + + res.json({ + success: true, + show_review: showReview, + google_review_url: googleReviewUrl || null, + referral_code: order ? await getOrCreateReferralCode(email, (order.customer_name as string) || "") : null, + }); + } catch (err) { + console.error("[survey] Error:", err); + res.status(500).json({ error: "Survey submission failed." }); + } +}); + +// POST /api/v1/referral/check — Validate a referral code +router.post("/api/v1/referral/check", async (req: Request, res: Response) => { + try { + const { code } = req.body ?? {}; + if (!code) { + res.status(400).json({ valid: false, error: "code required." }); + return; + } + + const result = await pool.query( + "SELECT code, customer_name FROM referral_codes WHERE code = $1", + [code.toUpperCase()], + ); + + if (result.rows.length === 0) { + res.json({ valid: false }); + return; + } + + res.json({ + valid: true, + referrer_name: (result.rows[0] as Record).customer_name, + }); + } catch { + res.status(500).json({ valid: false, error: "Check failed." }); + } +}); + +// GET /api/v1/referral/:email — Get referral info for a customer +router.get("/api/v1/referral/:email", async (req: Request, res: Response) => { + try { + const email = req.params.email; + const result = await pool.query( + "SELECT code, times_used, total_earned_cents, balance_cents FROM referral_codes WHERE customer_email = $1", + [email], + ); + + if (result.rows.length === 0) { + res.json({ has_code: false }); + return; + } + + const r = result.rows[0] as Record; + res.json({ + has_code: true, + code: r.code, + times_used: r.times_used, + total_earned: ((r.total_earned_cents as number) / 100).toFixed(2), + balance: ((r.balance_cents as number) / 100).toFixed(2), + }); + } catch { + res.status(500).json({ error: "Failed." }); + } +}); + +async function getOrCreateReferralCode(email: string, name: string): Promise { + // Check if already exists + const existing = await pool.query( + "SELECT code FROM referral_codes WHERE customer_email = $1", + [email], + ); + if (existing.rows.length > 0) { + return (existing.rows[0] as Record).code as string; + } + + // Generate code from name + const cleanName = name.replace(/[^a-zA-Z]/g, "").toUpperCase().substring(0, 12); + let code = `REF-${cleanName || "CUSTOMER"}`; + + // Ensure unique + const check = await pool.query("SELECT 1 FROM referral_codes WHERE code = $1", [code]); + if (check.rows.length > 0) { + code = `${code}${Math.floor(Math.random() * 99)}`; + } + + await pool.query( + `INSERT INTO referral_codes (code, customer_email, customer_name) + VALUES ($1, $2, $3) ON CONFLICT (customer_email) DO NOTHING`, + [code, email, name], + ); + + return code; +} + +export default router; diff --git a/infra/ansible/roles/worker-crons/defaults/main.yml b/infra/ansible/roles/worker-crons/defaults/main.yml index 090dc2f..8ba9fa5 100644 --- a/infra/ansible/roles/worker-crons/defaults/main.yml +++ b/infra/ansible/roles/worker-crons/defaults/main.yml @@ -134,6 +134,14 @@ worker_crons: on_calendar: "*-*-* 08:00:00 UTC" persistent: true + # Post-completion emails — every 15 minutes. + # Sends completion confirmations + 24h follow-up (survey + referral). + - name: pw-completion-emails + description: Send post-completion and follow-up emails + module: scripts.workers.completion_emails + on_calendar: "*-*-* *:0/15:00 UTC" + persistent: false + # Florida entity downloader — daily 07:30 UTC. # Downloads daily diffs from FL Sunbiz SFTP (free public access). - name: pw-fl-entity-downloader diff --git a/scripts/workers/completion_emails.py b/scripts/workers/completion_emails.py new file mode 100644 index 0000000..8856172 --- /dev/null +++ b/scripts/workers/completion_emails.py @@ -0,0 +1,274 @@ +"""Post-completion email flow. + +Sends two emails after an order is marked completed: +1. Immediately: "Your filing is complete!" with documents +2. 24 hours later: Exit survey + referral program + review ask + +Run via cron every 15 minutes: + python -m scripts.workers.completion_emails +""" + +import json +import logging +import os +import smtplib +import sys +import urllib.request +from datetime import datetime, timedelta, timezone +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import psycopg2 + +LOG = logging.getLogger("workers.completion_emails") + +DB_URL = os.getenv("DATABASE_URL", "") +SMTP_FROM = "noreply@performancewest.net" +SITE_URL = os.getenv("SITE_URL", "https://performancewest.net") +API_URL = os.getenv("API_URL", "https://api.performancewest.net") +GOOGLE_REVIEW_URL = os.getenv("GOOGLE_REVIEW_URL", "") +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") + + +def send_email(to: str, subject: str, html: str): + """Send an HTML email via local Postfix.""" + msg = MIMEMultipart("alternative") + msg["From"] = f"Performance West <{SMTP_FROM}>" + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(html, "html")) + + with smtplib.SMTP("localhost", 25) as s: + s.sendmail(SMTP_FROM, [to], msg.as_string()) + + +def get_or_create_referral_code(conn, email: str, name: str) -> str: + """Get or create a referral code for a customer.""" + cur = conn.cursor() + cur.execute("SELECT code FROM referral_codes WHERE customer_email = %s", (email,)) + row = cur.fetchone() + if row: + return row[0] + + clean = "".join(c for c in name.upper() if c.isalpha())[:12] + code = f"REF-{clean or 'CUSTOMER'}" + + # Ensure unique + cur.execute("SELECT 1 FROM referral_codes WHERE code = %s", (code,)) + if cur.fetchone(): + import random + code = f"{code}{random.randint(10, 99)}" + + try: + cur.execute( + "INSERT INTO referral_codes (code, customer_email, customer_name) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", + (code, email, name), + ) + conn.commit() + except Exception: + conn.rollback() + + return code + + +def completion_email_html(order_number: str, service_name: str, customer_name: str) -> str: + """Generate the completion email HTML.""" + first = customer_name.split()[0] if customer_name else "there" + return f""" + + +
+
+ Performance West +
+
+
+
+ +
+

Your {service_name} Is Complete!

+
+

Hi {first},

+

+ Great news — your {service_name} has been completed and filed successfully. + Order reference: {order_number}. +

+

+ You can view your documents and track all your filings in your client portal: +

+ +

+ If you have any questions about your filing, reply to this email or call us at + (888) 411-0383. +

+

— The Performance West Team

+
+
+ Performance West Inc. · (888) 411-0383 · performancewest.net +
+
+""" + + +def followup_email_html( + order_number: str, + service_name: str, + customer_name: str, + referral_code: str, +) -> str: + """Generate the 24h follow-up email with survey + referral.""" + first = customer_name.split()[0] if customer_name else "there" + survey_url = f"{SITE_URL}/survey?order={order_number}" + + return f""" + + +
+
+ Performance West +
+
+

How did we do, {first}?

+ +

+ Your {service_name} was completed yesterday. We'd love your feedback — it takes 30 seconds. +

+ + +
+

Rate your experience:

+
+ + + + + +
+

Click a star to rate

+
+ +
+ + +
+

Know another trucker?

+

+ Share your referral code and earn $25 credit for each order they place. +

+
+

Your referral code:

+

{referral_code}

+
+

+ They can enter this code at checkout. You earn $25 credit per order — no limit. +

+
+ +

+ Thank you for choosing Performance West. We're here whenever you need us. +

+

— The Performance West Team

+
+
+ Performance West Inc. · (888) 411-0383 · performancewest.net +
+
+""" + + +def process_completions(): + """Find completed orders and send emails.""" + conn = psycopg2.connect(DB_URL) + cur = conn.cursor() + + now = datetime.now(timezone.utc) + + # 1. Send completion emails (order completed, email not sent yet) + cur.execute(""" + SELECT order_number, customer_email, customer_name, service_name, updated_at + FROM compliance_orders + WHERE payment_status = 'paid' + AND completion_email_sent_at IS NULL + AND updated_at < NOW() - interval '5 minutes' + AND ( + intake_data->>'status' = 'completed' + OR intake_data->>'status' = 'delivered' + OR intake_data->>'status' = 'filed' + ) + ORDER BY updated_at + LIMIT 20 + """) + completions = cur.fetchall() + + for row in completions: + order_number, email, name, service_name, updated_at = row + if not email: + continue + + try: + html = completion_email_html(order_number, service_name or "compliance filing", name or "") + send_email(email, f"✅ Your {service_name or 'filing'} is complete — {order_number}", html) + + cur.execute( + "UPDATE compliance_orders SET completion_email_sent_at = NOW() WHERE order_number = %s", + (order_number,), + ) + conn.commit() + LOG.info("[completion] Sent completion email for %s to %s", order_number, email) + except Exception as exc: + LOG.error("[completion] Failed for %s: %s", order_number, exc) + conn.rollback() + + # 2. Send 24h follow-up emails (completion email sent 24h+ ago, follow-up not sent) + cur.execute(""" + SELECT order_number, customer_email, customer_name, service_name + FROM compliance_orders + WHERE payment_status = 'paid' + AND completion_email_sent_at IS NOT NULL + AND completion_email_sent_at < NOW() - interval '24 hours' + AND followup_email_sent_at IS NULL + ORDER BY completion_email_sent_at + LIMIT 20 + """) + followups = cur.fetchall() + + for row in followups: + order_number, email, name, service_name = row + if not email: + continue + + try: + referral_code = get_or_create_referral_code(conn, email, name or "") + html = followup_email_html(order_number, service_name or "compliance filing", name or "", referral_code) + send_email(email, f"How was your experience? + Earn $25 referrals", html) + + cur.execute( + "UPDATE compliance_orders SET followup_email_sent_at = NOW(), referral_code = %s WHERE order_number = %s", + (referral_code, order_number), + ) + conn.commit() + LOG.info("[followup] Sent follow-up email for %s to %s (ref: %s)", order_number, email, referral_code) + except Exception as exc: + LOG.error("[followup] Failed for %s: %s", order_number, exc) + conn.rollback() + + conn.close() + + total = len(completions) + len(followups) + if total: + LOG.info("[completion_emails] Processed %d completion + %d follow-up emails", len(completions), len(followups)) + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s %(message)s", + ) + process_completions() + + +if __name__ == "__main__": + main() diff --git a/site/public/survey/index.html b/site/public/survey/index.html new file mode 100644 index 0000000..e38e5af --- /dev/null +++ b/site/public/survey/index.html @@ -0,0 +1,172 @@ + + + + + + +How Did We Do? | Performance West + + + + + + +
+ Performance West +
+ +
+ +
+

How was your experience?

+

Your feedback helps us serve truckers better.

+ +
+ + + + + +
+ +

+ + + + + +
+ + + +
+ + + + + +