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:
parent
6b20ba7f08
commit
ad3d189b2b
6 changed files with 655 additions and 0 deletions
47
api/migrations/081_referral_and_survey.sql
Normal file
47
api/migrations/081_referral_and_survey.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
152
api/src/routes/survey.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue