post-completion flow: survey, referral program, review ask

- 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
This commit is contained in:
justin 2026-05-30 21:22:14 -05:00
parent 6b20ba7f08
commit ad3d189b2b
6 changed files with 655 additions and 0 deletions

View file

@ -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()

152
api/src/routes/survey.ts Normal file
View file

@ -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<string, unknown> | 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<string, unknown>).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<string, unknown>;
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<string> {
// 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<string, unknown>).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;