new-site/api/src/index.ts
justin 3d4226e95c ifta: 3-touch business-day cadence + 'I already filed it' suppression
- Multi-touch reminders at 10/7/4 BUSINESS days before each deadline (weekends
  skipped; biz-day math so a touch never lands purely on a weekend with no
  runway). Escalating tone soft -> urgent -> last-chance, with the 'almost too
  late to DIY, we can still file it' angle so it's a convenience sale, not a free
  reminder service. ifta_touch_no tracks the highest touch sent so each touch
  hits only carriers below that level; never repeats a touch.
- 'I already filed it' one-click link: HMAC-tokenized GET /api/v1/ifta/filed
  (token matches between Python builder and api/src/routes/ifta.ts -- verified
  identical output), records ifta_self_filed_at, friendly confirmation page,
  stops further touches this cycle + gives DIY-vs-prospect signal. Builder
  excludes self-filed carriers.
- migration 094 (ifta_touch_no) + 095 (ifta_self_filed_at); cycle reset clears
  both each new quarter. Verified: biz-day touch schedule, token cross-match.
2026-06-13 23:41:14 -05:00

200 lines
7.3 KiB
TypeScript

import express from "express";
import cookieParser from "cookie-parser";
import { config } from "./config.js";
import { pool, pgHealthy } from "./db.js";
import { securityHeaders, extractClientIp } from "./middleware/security.js";
import { corsMiddleware } from "./middleware/cors.js";
import { globalLimiter } from "./middleware/rate-limit.js";
import { errorHandler } from "./middleware/error-handler.js";
import { accessLog } from "./middleware/access-log.js";
import healthRouter from "./routes/health.js";
import subscribeRouter from "./routes/subscribe.js";
import ticketsRouter from "./routes/tickets.js";
import quotesRouter from "./routes/quotes.js";
import formationsRouter from "./routes/formations.js";
import discountsRouter from "./routes/discounts.js";
import iftaRouter from "./routes/ifta.js";
import adminRouter from "./routes/admin.js";
import webhooksRouter from "./routes/webhooks.js";
import identityRouter from "./routes/identity.js";
import refundsRouter from "./routes/refunds.js";
import agentsRouter from "./routes/agents.js";
import bundlesRouter from "./routes/bundles.js";
import entitiesRouter from "./routes/entities.js";
import idUploadRouter from "./routes/id-upload.js";
import canadaCrtcRouter from "./routes/canada-crtc.js";
import checkoutRouter from "./routes/checkout.js";
import ambLocationsRouter from "./routes/amb-locations.js";
import paymentMethodsRouter from "./routes/payment-methods.js";
import paypalRouter from "./routes/paypal.js";
import portalAuthRouter from "./routes/portal-auth.js";
import portalRouter from "./routes/portal.js";
import portalSetupRouter from "./routes/portal-setup.js";
import portalEsignRouter from "./routes/portal-esign.js";
import fccLookupRouter from "./routes/fcc-lookup.js";
import telecomEntitiesRouter from "./routes/telecom-entities.js";
import complianceOrdersRouter from "./routes/compliance-orders.js";
import cdrRouter from "./routes/cdr.js";
import iccRouter from "./routes/icc.js";
import resellerCertsRouter from "./routes/reseller-certs.js";
import lnpaRegionsRouter from "./routes/lnpa-regions.js";
import fccFilingsRouter from "./routes/fcc-filings.js";
import adminCryptoRouter from "./routes/admin-crypto.js";
import foreignQualRouter from "./routes/foreign-qualification.js";
import corpStatusRouter from "./routes/corp-status.js";
import portalRmdReviewRouter from "./routes/portal-rmd-review.js";
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 npiLookupRouter from "./routes/npi-lookup.js";
import surveyRouter from "./routes/survey.js";
import orderTimelineRouter from "./routes/order-timeline.js";
const app = express();
// Trust first proxy (nginx) in production
if (config.nodeEnv === "production") {
app.set("trust proxy", 1);
}
// --- Middleware stack (order matters) ---
app.use(securityHeaders);
app.use(extractClientIp);
app.use(accessLog);
app.use(corsMiddleware);
app.use(cookieParser());
app.use(globalLimiter);
// Stripe webhook — raw body MUST be preserved for signature verification.
// Mount BEFORE express.json() so the Buffer is not parsed away.
app.use("/api/v1/webhooks/stripe", express.raw({ type: "application/json" }));
app.use(identityRouter); // identity webhook uses raw() internally on its specific route
// Photo ID upload needs larger body (resized images up to ~3MB as base64)
app.use("/api/v1/id-upload", express.json({ limit: "5mb" }));
app.use(express.json({ limit: "512kb" })); // 512kb for eSign signature PNG base64
// Reject non-JSON content types on POST/PUT/PATCH
app.use((req, res, next) => {
if (["POST", "PUT", "PATCH"].includes(req.method) && !req.is("json")) {
res.status(415).json({ error: "Content-Type must be application/json" });
return;
}
next();
});
// --- Routes ---
app.use(healthRouter);
app.use(subscribeRouter);
app.use(ticketsRouter);
app.use(quotesRouter);
app.use(formationsRouter);
app.use(discountsRouter);
app.use(iftaRouter);
app.use(adminRouter);
app.use(webhooksRouter);
app.use(refundsRouter);
app.use(agentsRouter);
app.use(bundlesRouter);
app.use(entitiesRouter);
app.use(idUploadRouter);
app.use(canadaCrtcRouter);
app.use(checkoutRouter);
app.use(ambLocationsRouter);
app.use(paymentMethodsRouter);
app.use(paypalRouter);
app.use("/api/v1/auth", portalAuthRouter);
app.use(portalSetupRouter);
app.use(portalEsignRouter);
app.use(portalRmdReviewRouter);
app.use(portalEsignGenericRouter);
app.use("/api/v1/portal", portalRouter); // Must be AFTER specific portal routes (uses catch-all customer-auth)
app.use(fccLookupRouter);
app.use(corpStatusRouter);
app.use(telecomEntitiesRouter);
app.use(complianceOrdersRouter);
app.use(cdrRouter);
app.use(iccRouter);
app.use(resellerCertsRouter);
app.use(lnpaRegionsRouter);
app.use(fccFilingsRouter);
app.use(foreignQualRouter);
app.use(pucRouter);
app.use(fccCarrierRegRouter);
app.use(dotLookupRouter);
app.use(npiLookupRouter);
app.use(surveyRouter);
app.use(orderTimelineRouter);
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()
// --- Error handler (must be last) ---
app.use(errorHandler);
// --- Start ---
async function start() {
// Verify database connection
const dbOk = await pgHealthy();
if (dbOk) {
console.log(`[db] PostgreSQL connected`);
} else {
console.warn(`[db] PostgreSQL unreachable — API will start but DB-dependent routes will fail`);
}
// Pre-warm FX rate cache
import("./fx.js").then(fx => fx.warmFxCache()).catch(err => console.warn("[fx] warmup failed:", err));
// Log ERPNext/Listmonk configuration status (non-blocking)
if (config.erpnext.apiKey) {
console.log(`[erpnext] Configured at ${config.erpnext.url}`);
try {
const r = await fetch(`${config.erpnext.url.replace(/\/$/, "")}/api/method/ping`, {
headers: {
Authorization: `token ${config.erpnext.apiKey}:${config.erpnext.apiSecret}`,
"X-Frappe-Site-Name": config.erpnext.siteName,
},
});
if (r.ok) {
console.log("[erpnext] Connectivity check passed");
} else {
console.warn(`[erpnext] Connectivity check failed (HTTP ${r.status})`);
}
} catch (err) {
console.warn("[erpnext] Connectivity check failed (network error):", err);
}
} else {
console.warn(`[erpnext] No API key configured — ERPNext integration disabled`);
}
if (config.listmonk.password) {
console.log(`[listmonk] Configured at ${config.listmonk.url}`);
} else {
console.warn(`[listmonk] No credentials configured — Listmonk integration disabled`);
}
const host = "0.0.0.0"; // bind all interfaces — nginx on host proxies to Docker container port
app.listen(config.port, host, () => {
console.log(`[api] Performance West API listening on ${host}:${config.port} (${config.nodeEnv})`);
});
}
start().catch((err) => {
console.error("[api] Fatal startup error:", err);
process.exit(1);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("[api] SIGTERM received, shutting down...");
await pool.end();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("[api] SIGINT received, shutting down...");
await pool.end();
process.exit(0);
});