Initial GIT Veriflo - Backend

This commit is contained in:
MOHAN 2026-03-20 14:54:08 +05:30
commit bb43aed936
27 changed files with 7262 additions and 0 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
PORT=3000
VERTIFLO_DASHBOARD_ORIGIN="http://localhost:5174"
VERTIFLO_ENABLE_WHATSAPP_CLIENT=true
VERTIFLO_MESSAGE_TEMPLATE_MAX_CHARS=320
VERTIFLO_SANDBOX_MONTHLY_MESSAGE_LIMIT=500
VERTIFLO_LIVE_MONTHLY_MESSAGE_LIMIT=100
VERTIFLO_SANDBOX_INTEGRATION_QUOTA_UNITS=1
VERTIFLO_UPI_ID="your-upi-id@bank"
VERTIFLO_UPI_NAME="Veriflo"
VERTIFLO_SUPERADMIN_EMAIL="superadmin@veriflo.local"
VERTIFLO_SUPERADMIN_PASSWORD="SuperAdmin@123"
VERTIFLO_SUPERADMIN_NAME="Super Admin"

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.wwebjs_auth/
.wwebjs_cache/
.env

BIN
database.sqlite Normal file

Binary file not shown.

BIN
database.sqlite-shm Normal file

Binary file not shown.

BIN
database.sqlite-wal Normal file

Binary file not shown.

144
index.js Normal file
View File

@ -0,0 +1,144 @@
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const { Client, LocalAuth } = require("whatsapp-web.js");
const qrcode = require("qrcode-terminal");
const { loadEnvFile } = require("./src/utils/env");
const { initSchema } = require("./src/db");
const { requestLogger } = require("./src/utils/logger");
loadEnvFile();
const app = express();
const PORT = process.env.PORT || 3000;
const dashboardOrigin = process.env.VERTIFLO_DASHBOARD_ORIGIN || 'http://localhost:5174';
const whatsappEnabled = process.env.VERTIFLO_ENABLE_WHATSAPP_CLIENT !== 'false';
// Initialize SQLite Schema
initSchema();
// --- Global Security & Middleware ---
app.use(helmet());
app.use(cors({
origin: [dashboardOrigin, 'http://localhost:5173'],
credentials: false,
}));
app.use(express.json());
// Request logging middleware - logs all requests with timestamps to file
app.use(requestLogger);
// Import Routers
const authRoutes = require('./src/routes/auth');
const userRoutes = require('./src/routes/user');
const otpRoutes = require('./src/routes/otp');
const analyticsRoutes = require('./src/routes/analytics');
const adminRoutes = require('./src/routes/admin');
// Trust proxy if running behind Nginx/Vercel to get real IPs for analytics
app.set('trust proxy', 1);
// Global Rate Limiter — 5000 reqs / 15 mins per IP (development-friendly, still prevents abuse)
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5000,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests from this IP. Please try again later.' },
skip: (req) => req.ip === '127.0.0.1' || req.ip === '::1', // Skip rate limiting for localhost
});
app.use(globalLimiter);
// --- WhatsApp Client Wrapper ---
// We attach the client and its state to `app.locals` so routes can access it
app.locals.waReady = false;
app.locals.waInitError = null;
app.locals.waClient = null;
if (whatsappEnabled) {
const client = new Client({
authStrategy: new LocalAuth(),
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-features=site-per-process',
'--ignore-certificate-errors',
'--ignore-certificate-errors-spki-list'
]
}
});
client.on("qr", (qr) => {
console.log("Scan this QR code with WhatsApp:");
qrcode.generate(qr, { small: true });
});
client.on("ready", () => {
app.locals.waReady = true;
app.locals.waInitError = null;
console.log("WhatsApp client is ready!");
});
client.on("authenticated", () => {
console.log("Authenticated successfully.");
});
client.on("auth_failure", (msg) => {
app.locals.waInitError = typeof msg === 'string' ? msg : 'WhatsApp authentication failed.';
console.error("Authentication failed:", msg);
});
client.on("disconnected", (reason) => {
app.locals.waReady = false;
app.locals.waInitError = typeof reason === 'string' ? reason : 'WhatsApp client disconnected.';
console.log("Client disconnected:", reason);
});
client.initialize().catch((error) => {
app.locals.waReady = false;
app.locals.waInitError = error.message;
console.error("WhatsApp client failed to initialize:", error.message);
});
app.locals.waClient = client;
} else {
app.locals.waInitError = 'WhatsApp client disabled by configuration.';
console.log('WhatsApp client initialization skipped because VERTIFLO_ENABLE_WHATSAPP_CLIENT=false');
}
// --- Express Routes ---
app.get("/status", (req, res) => {
res.json({
connected: app.locals.waReady,
whatsapp_enabled: whatsappEnabled,
whatsapp_error: app.locals.waInitError,
timestamp: new Date(),
port: PORT,
dashboard_origin: dashboardOrigin,
});
});
// --- Register Internal Dashboard APIs ---
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
app.use('/api/user/analytics', analyticsRoutes);
app.use('/api/admin', adminRoutes);
// --- Register Public Facing Edge SDK APIs ---
app.use('/v1/otp', otpRoutes);
// Legacy /send route temporarily preserved for reference,
// but it will be replaced by the secured /v1/otp/send SDK endpoint later
app.post("/send", async (req, res) => {
if (!app.locals.waReady) return res.status(503).json({ error: "WhatsApp not ready" });
res.status(410).json({ error: "Use /v1/otp/send instead" });
});
// --- Server Startup ---
app.listen(PORT, () => {
console.log(`Veriflo API server running on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,42 @@
[2026-03-12T20:57:48.932Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 29ms
[2026-03-12T20:57:48.959Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:11.121Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:11.133Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:58:13.074Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:13.078Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-12T20:58:13.091Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:13.098Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:13.722Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 6ms
[2026-03-12T20:58:14.425Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 185ms
[2026-03-12T20:58:14.517Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:58:14.671Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 59ms
[2026-03-12T20:58:21.921Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 8ms
[2026-03-12T20:58:21.997Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 13ms
[2026-03-12T20:58:37.736Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:58:37.888Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T20:58:38.746Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-12T20:58:38.758Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 6ms
[2026-03-12T20:58:38.776Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T20:58:38.783Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T20:58:40.231Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:58:40.244Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:59:42.915Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 160ms
[2026-03-12T20:59:43.083Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T20:59:50.093Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 123ms
[2026-03-12T20:59:50.237Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T21:00:07.223Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T21:00:07.245Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T21:00:07.757Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T21:00:07.803Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T21:00:09.572Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-12T21:00:09.702Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T21:00:10.197Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 40ms
[2026-03-12T21:00:10.679Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 342ms
[2026-03-12T21:00:10.784Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 102ms
[2026-03-12T21:00:10.915Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-12T21:00:10.917Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T21:00:11.054Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 104ms
[2026-03-12T21:00:11.337Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T21:00:11.349Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-12T21:00:12.382Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-12T21:00:12.516Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 13ms

View File

@ -0,0 +1,3 @@
[2026-03-13T07:50:53.259Z] POST /signup/send-otp - | IP: ::1 | Status: 503 | Duration: 408ms
[2026-03-13T07:51:52.997Z] POST /signup/send-otp - | IP: ::1 | Status: 200 | Duration: 515ms
[2026-03-13T07:52:15.833Z] POST /signup/send-otp - | IP: ::1 | Status: 200 | Duration: 576ms

View File

@ -0,0 +1,4 @@
[2026-03-15T03:59:10.909Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 26ms
[2026-03-15T03:59:10.927Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-15T03:59:10.938Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-15T03:59:10.945Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 3ms

View File

@ -0,0 +1,663 @@
[2026-03-19T18:59:19.004Z] POST /login - | IP: ::1 | Status: 401 | Duration: 64ms
[2026-03-19T18:59:20.367Z] POST /login - | IP: ::1 | Status: 401 | Duration: 56ms
[2026-03-19T18:59:20.838Z] POST /login - | IP: ::1 | Status: 401 | Duration: 60ms
[2026-03-19T18:59:21.100Z] POST /login - | IP: ::1 | Status: 401 | Duration: 55ms
[2026-03-19T18:59:21.351Z] POST /login - | IP: ::1 | Status: 401 | Duration: 58ms
[2026-03-19T18:59:24.567Z] POST /login - | IP: ::1 | Status: 401 | Duration: 58ms
[2026-03-19T18:59:25.148Z] POST /login - | IP: ::1 | Status: 401 | Duration: 61ms
[2026-03-19T18:59:25.268Z] POST /login - | IP: ::1 | Status: 401 | Duration: 58ms
[2026-03-19T18:59:25.393Z] POST /login - | IP: ::1 | Status: 401 | Duration: 62ms
[2026-03-19T18:59:32.399Z] POST /signup/send-otp - | IP: ::1 | Status: 200 | Duration: 1637ms
[2026-03-19T19:00:04.337Z] POST /signup/verify - | IP: ::1 | Status: 409 | Duration: 19ms
[2026-03-19T19:00:05.233Z] POST /signup/verify - | IP: ::1 | Status: 409 | Duration: 18ms
[2026-03-19T19:00:05.350Z] POST /signup/verify - | IP: ::1 | Status: 409 | Duration: 17ms
[2026-03-19T19:00:05.559Z] POST /signup/verify - | IP: ::1 | Status: 409 | Duration: 14ms
[2026-03-19T19:00:09.789Z] POST /signup/verify - | IP: ::1 | Status: 201 | Duration: 91ms
[2026-03-19T19:00:09.924Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 7ms
[2026-03-19T19:00:09.926Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:00:09.932Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:00:09.933Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:01:36.135Z] POST /login - | IP: ::1 | Status: 200 | Duration: 71ms
[2026-03-19T19:01:36.156Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:01:36.158Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:01:36.163Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:01:36.165Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:03:09.319Z] GET /api-keys - | IP: ::1 | Status: 200 | Duration: 8ms
[2026-03-19T19:03:09.327Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:03:47.820Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:03:47.822Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:06:30.473Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 5ms
[2026-03-19T19:06:30.481Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:09:51.454Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 2ms
[2026-03-19T19:09:52.207Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 1ms
[2026-03-19T19:09:52.391Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 0ms
[2026-03-19T19:09:52.593Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 0ms
[2026-03-19T19:09:52.747Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 1ms
[2026-03-19T19:09:52.968Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 0ms
[2026-03-19T19:09:53.275Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 0ms
[2026-03-19T19:09:53.571Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 1ms
[2026-03-19T19:09:53.790Z] POST /test-webhook - | IP: ::1 | Status: 400 | Duration: 0ms
[2026-03-19T19:10:01.901Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:10:01.907Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:10:03.106Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:10:03.116Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:12.949Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 8ms
[2026-03-19T19:11:12.958Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:12.960Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:12.968Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:11:15.437Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:15.444Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:30.862Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 3ms
[2026-03-19T19:11:30.868Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 200 | Duration: 3ms
[2026-03-19T19:11:30.874Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:11:30.880Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:11:32.238Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:32.242Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:33.521Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:33.525Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:33.527Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:33.531Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:11:34.107Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:11:34.113Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:13:11.844Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:13:11.846Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:13:11.849Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:13:11.851Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:13:14.174Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:13:14.180Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:13:14.182Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:13:14.186Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:13:47.267Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:13:47.273Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:14:00.012Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:14:00.014Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:14:00.019Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:14:00.025Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:14:52.638Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:14:52.644Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:16:21.974Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 4ms
[2026-03-19T19:16:21.984Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 2ms
[2026-03-19T19:16:25.626Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:16:25.633Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 0ms
[2026-03-19T19:16:26.327Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:16:26.333Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 1ms
[2026-03-19T19:16:27.068Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:16:27.078Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 1ms
[2026-03-19T19:16:27.295Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:16:27.311Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 1ms
[2026-03-19T19:16:27.616Z] POST /trial/apply - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:16:27.625Z] GET /profile - | IP: ::1 | Status: 500 | Duration: 1ms
[2026-03-19T19:16:30.002Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 3ms
[2026-03-19T19:16:30.010Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:16:30.013Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:16:30.017Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:16:31.520Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:16:31.530Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:17:57.244Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 5ms
[2026-03-19T19:17:57.250Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:17:57.252Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:17:57.256Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:18:27.681Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:18:27.685Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:18:29.536Z] POST /trial/activate - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:18:29.549Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:22:28.666Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 7ms
[2026-03-19T19:22:28.677Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:22:51.599Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:22:51.601Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:22:51.606Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:51.608Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:52.309Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:52.313Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:22:53.694Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:53.701Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:53.705Z] GET /api-keys - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:22:53.711Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:54.403Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:54.408Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:55.269Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:55.271Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:55.276Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:55.278Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:57.554Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:22:57.559Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:57.562Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:57.568Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:58.238Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:22:58.244Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:58.908Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:58.909Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:58.913Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:58.914Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:22:59.533Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:22:59.538Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:23:02.100Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:23:02.105Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:23:42.202Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:23:42.208Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:23:53.572Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:23:53.579Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:23:53.582Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:23:53.587Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:24:07.552Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:24:07.557Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:24:18.611Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:24:18.620Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:24:34.398Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:24:34.400Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:24:34.406Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:24:34.412Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:24:36.175Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:24:36.184Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:25:46.355Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:25:46.357Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:25:46.359Z] GET /status - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:25:46.360Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:25:46.362Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:25:47.879Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:25:47.885Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:35.524Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:35.529Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:35.532Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:35.538Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:55.775Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:26:55.785Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:05.367Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:05.372Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:17.582Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:17.584Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:17.589Z] GET /logs {"limit":"50"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:17.592Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:27:18.780Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:18.786Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:27.807Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:27.813Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:27.816Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:27.821Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:31.840Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:31.848Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:27:38.793Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:39:24.803Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-19T19:40:50.808Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:44:14.602Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:44:14.608Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:14.611Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:14.617Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:19.724Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:44:19.728Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:32.693Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:44:32.695Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:32.699Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:32.701Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:36.244Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:44:36.246Z] GET /billing/payments - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:44:36.251Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:44:36.256Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:36.878Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-19T19:46:36.885Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.140Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.142Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.148Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.150Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.771Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:40.777Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:41.573Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:41.575Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:41.581Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:46:41.583Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:46:42.305Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:46:42.310Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:46:49.997Z] POST /test-otp - | IP: ::1 | Status: 503 | Duration: 1ms
[2026-03-19T19:46:52.222Z] POST /test-otp - | IP: ::1 | Status: 503 | Duration: 1ms
[2026-03-19T19:46:52.359Z] POST /test-otp - | IP: ::1 | Status: 503 | Duration: 1ms
[2026-03-19T19:46:52.540Z] POST /test-otp - | IP: ::1 | Status: 503 | Duration: 1ms
[2026-03-19T19:47:25.951Z] POST /test-otp - | IP: ::1 | Status: 200 | Duration: 877ms
[2026-03-19T19:47:33.805Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 4ms
[2026-03-19T19:47:33.807Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:47:33.809Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:47:33.811Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:47:33.813Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:47:33.816Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:47:33.817Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:47:33.819Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:48:45.818Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:45.824Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:46.366Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:46.367Z] GET /billing/config - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:48:46.368Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:46.370Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:46.371Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:46.372Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:47.119Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:47.121Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:48:47.124Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:47.127Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:49.137Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:49.143Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:50.939Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:50.941Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:50.942Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:50.946Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:50.948Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:50.951Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:54.672Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:48:54.675Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:54.679Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:54.682Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:55.279Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:55.285Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:48:58.636Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:48:58.651Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:48:58.654Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:48:58.659Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:48:58.664Z] GET /status - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:49:09.900Z] POST /test-otp - | IP: ::1 | Status: 200 | Duration: 1195ms
[2026-03-19T19:49:17.406Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:49:17.408Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:17.409Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:17.415Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:17.417Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:17.421Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:19.070Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:19.071Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:19.073Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:19.075Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:19.077Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:19.080Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:36.204Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:49:36.207Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:49:36.214Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:49:36.219Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:40.933Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:40.938Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:42.838Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:42.845Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:49:42.847Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:42.853Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:43.791Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:43.793Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:43.798Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:43.800Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:44.437Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:44.440Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:49:51.296Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:49:51.303Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:50:34.068Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:50:34.070Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:50:34.073Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:50:34.075Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:50:36.289Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:50:36.293Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:50:36.297Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:50:36.301Z] GET /status - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:50:36.308Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:50:36.312Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:50:36.318Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:50:36.321Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:50:36.330Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 4ms
[2026-03-19T19:50:36.338Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:51:16.352Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:16.357Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:17.243Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:17.251Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:17.256Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:17.264Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:51:18.603Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:18.609Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:19.806Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:19.814Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:19.818Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:51:19.825Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:20.336Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:20.341Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:24.988Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:24.989Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:24.990Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:24.995Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:51:24.997Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:25.000Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:51.084Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:51.088Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:53.531Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:51:53.540Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:51:53.541Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:51:53.543Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:51:53.545Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:56.615Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:56.616Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:56.618Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:51:56.623Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:51:56.625Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:51:56.628Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:52:23.966Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:52:23.969Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:52:23.974Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:52:23.977Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:52:39.104Z] POST /test-otp - | IP: ::1 | Status: 200 | Duration: 891ms
[2026-03-19T19:53:03.950Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:53:03.952Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:03.953Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:03.956Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:03.957Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:03.960Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:06.871Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:53:06.873Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:53:06.877Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:06.879Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.787Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.789Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.791Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.794Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.796Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:07.800Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:08.437Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:08.444Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:11.373Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:53:11.382Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:53:11.383Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:53:11.386Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:53:11.388Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:12.934Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:12.935Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:12.937Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:12.941Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:12.943Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:12.945Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:13.789Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:13.795Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:16.976Z] POST /test-otp - | IP: ::1 | Status: 403 | Duration: 1ms
[2026-03-19T19:53:29.302Z] POST /test-otp - | IP: ::1 | Status: 200 | Duration: 1824ms
[2026-03-19T19:53:32.150Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:53:32.152Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:53:32.158Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.162Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.808Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T19:53:32.810Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.814Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.817Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.820Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:32.821Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:34.869Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:34.870Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:34.875Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:34.877Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:52.083Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:52.085Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:52.086Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:52.089Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:52.091Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:52.092Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:53.715Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:53.719Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:54.471Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:53:54.473Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:54.479Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:53:54.481Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:55.186Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:55.191Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:53:55.883Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:53:55.887Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:46.141Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:46.142Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:54:46.143Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:54:46.148Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:46.150Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:54:46.151Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:54:46.867Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:46.872Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:49.706Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:54:49.713Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:49.716Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:49.723Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:54:50.704Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:54:50.711Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T19:55:14.720Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:14.725Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:55:15.398Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:15.403Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:15.408Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:15.414Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:55:16.177Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:16.188Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:35.167Z] GET /activity {"page":"1","page_size":"25"} | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:55:40.384Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:40.388Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:55:46.262Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:46.270Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:46.276Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:46.284Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:46.870Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:46.872Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:46.878Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:46.880Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:55:56.053Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:56.057Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:56.059Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:55:56.064Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:55:56.068Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:56.070Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:59.099Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:55:59.110Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:07.663Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:07.669Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.347Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.348Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:11.349Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:11.353Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.355Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:11.357Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.898Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.899Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.903Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:11.905Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:14.367Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:14.372Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:43.245Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:43.250Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:43.252Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:43.258Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:44.399Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:44.405Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:45.030Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:45.034Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:45.038Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:45.045Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:46.195Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:46.199Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:47.213Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:47.215Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:47.221Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:56:47.224Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:47.813Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:47.814Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:47.815Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:47.818Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:47.821Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:47.824Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:48.906Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:48.911Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:50.098Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.100Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.104Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.108Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:56:50.647Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.654Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.658Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:50.664Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:51.230Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:51.234Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:51.900Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:51.906Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:51.908Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:51.914Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:52.550Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:52.555Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:53.996Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:53.997Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:54.002Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:56:54.004Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:54.632Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:54.634Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:54.635Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:54.639Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:56:54.641Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:56:54.643Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.645Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.647Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.649Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.651Z] GET /status - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T19:59:29.654Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.656Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.659Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.661Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.662Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T19:59:29.664Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:29.667Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:59:29.677Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T19:59:31.241Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:31.246Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:59:35.240Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:59:35.243Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:35.245Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:35.248Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:35.250Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:35.252Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:47.595Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T19:59:47.602Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:47.605Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:47.610Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:48.180Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T19:59:48.187Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:17:26.076Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 3ms
[2026-03-19T20:17:26.082Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:17:26.085Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:17:26.091Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:17:29.050Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:17:29.055Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:10.923Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:18:10.927Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:18:10.932Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:18:10.936Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:13.293Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:13.295Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:13.296Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:13.300Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:13.305Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:13.307Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:22.338Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:22.341Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:22.347Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:22.351Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:27.582Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:27.584Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:27.585Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:27.589Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:27.593Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:27.595Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:29.141Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:29.145Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:37.849Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:37.850Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:37.852Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:37.854Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:37.856Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:37.857Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:38.478Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:38.479Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:38.486Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:38.488Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:39.277Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:39.283Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:40.194Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:40.199Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:40.201Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:18:40.209Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:18:46.707Z] POST /api-key - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:18:46.711Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:18:46.718Z] GET /api-keys - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:09.200Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:09.202Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:09.207Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:20:09.208Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:09.790Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:09.794Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:29.264Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:29.274Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:29.276Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:29.277Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:29.278Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T20:20:29.280Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:29.479Z] PUT /settings - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:29.491Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:29.494Z] GET /summary - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:20:29.495Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T20:20:29.498Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:20:29.501Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:20:32.621Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:32.623Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:32.629Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:32.631Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:34.153Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:34.155Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:34.158Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:34.160Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:34.163Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:34.166Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:37.401Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:37.405Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:39.873Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:39.875Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:39.878Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:39.880Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:41.816Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:41.818Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:41.820Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:41.824Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:41.826Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:41.827Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:42.432Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:42.438Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:43.052Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:20:43.058Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:20:43.060Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:20:43.068Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.427Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:01.435Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:01.438Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T20:21:01.440Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.442Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.447Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 3ms
[2026-03-19T20:21:01.450Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.452Z] GET /status - | IP: ::1 | Status: 200 | Duration: 0ms
[2026-03-19T20:21:01.454Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.457Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:21:01.459Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:01.466Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:04.275Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:04.282Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:04.875Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:04.880Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:04.882Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:04.889Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:20.178Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:20.187Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:20.818Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:20.827Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:20.833Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:20.841Z] GET /api-keys - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:21.466Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:21.469Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:21.473Z] GET /logs {"page":"1","page_size":"20"} | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:21.475Z] GET /summary - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:48.025Z] GET /profile - | IP: ::1 | Status: 200 | Duration: 4ms
[2026-03-19T20:21:48.032Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:49.002Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 2ms
[2026-03-19T20:21:49.005Z] GET /billing/payments - | IP: ::1 | Status: 200 | Duration: 2ms
[2026-03-19T20:21:49.008Z] GET /billing/config - | IP: ::1 | Status: 200 | Duration: 1ms
[2026-03-19T20:21:49.010Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:49.013Z] GET /billing/payments - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:49.015Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:21:49.583Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:21:49.585Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms
[2026-03-19T20:21:49.590Z] GET /profile - | IP: ::1 | Status: 304 | Duration: 1ms
[2026-03-19T20:21:49.592Z] GET /billing/config - | IP: ::1 | Status: 304 | Duration: 0ms

3294
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "veriflo-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "commonjs",
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"engines": {
"node": ">=20 <24"
},
"dependencies": {
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"puppeteer": "^24.38.0",
"qrcode-terminal": "^0.12.0",
"whatsapp-web.js": "^1.34.6"
}
}

249
src/db.js Normal file
View File

@ -0,0 +1,249 @@
const Database = require('better-sqlite3');
const path = require('path');
const bcrypt = require('bcrypt');
// Initialize DB (creates database.sqlite at project root if it doesn't exist)
const dbPath = path.join(__dirname, '..', 'database.sqlite');
const db = new Database(dbPath, {
// verbose: console.log
});
// Enable WAL mode for better concurrency and performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Initialize Schema
function initSchema() {
console.log('Initializing SQLite Schema...');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company TEXT,
email TEXT UNIQUE NOT NULL,
phone TEXT UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT 'Default Key',
key_hash TEXT NOT NULL,
prefix TEXT NOT NULL,
mode TEXT DEFAULT 'sandbox',
last_used_ip TEXT,
last_used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
sender_name TEXT DEFAULT '',
greeting TEXT DEFAULT '',
message_template TEXT DEFAULT '',
otp_length INTEGER DEFAULT 6,
expiry_seconds INTEGER DEFAULT 300,
environment_mode TEXT DEFAULT 'sandbox',
trial_status TEXT DEFAULT 'not_applied',
trial_applied_at DATETIME,
trial_activated_at DATETIME,
trial_bonus_credits REAL DEFAULT 0,
live_bonus_credits REAL DEFAULT 0,
webhook_url TEXT DEFAULT '',
return_otp_in_response BOOLEAN DEFAULT FALSE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS otp_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
api_key_id INTEGER,
phone TEXT NOT NULL,
otp_hash TEXT NOT NULL,
status TEXT DEFAULT 'pending',
expires_at DATETIME NOT NULL,
ip_address TEXT,
user_agent TEXT,
delivery_latency_ms INTEGER,
quota_units REAL DEFAULT 1,
environment_mode TEXT DEFAULT 'sandbox',
quota_mode TEXT DEFAULT 'sandbox',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (api_key_id) REFERENCES api_keys (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS signup_otps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT UNIQUE NOT NULL,
phone TEXT NOT NULL,
otp_hash TEXT NOT NULL,
name TEXT,
company TEXT,
email TEXT,
password_hash TEXT,
status TEXT DEFAULT 'sent',
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS password_reset_otps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
phone TEXT NOT NULL,
email TEXT NOT NULL,
otp_hash TEXT NOT NULL,
status TEXT DEFAULT 'sent',
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
meta TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS payment_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_ref TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
package_name TEXT,
credits INTEGER NOT NULL,
amount_inr REAL NOT NULL,
utr TEXT,
upi_link TEXT,
status TEXT DEFAULT 'pending',
admin_note TEXT,
approved_by INTEGER,
approved_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS payment_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
payments_enabled INTEGER DEFAULT 1,
upi_id TEXT DEFAULT '',
upi_name TEXT DEFAULT 'Veriflo',
payment_note TEXT DEFAULT '',
updated_by INTEGER,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
);
`);
const userColumns = db.prepare("PRAGMA table_info(users)").all();
const hasPhoneColumn = userColumns.some((column) => column.name === 'phone');
if (!hasPhoneColumn) {
db.exec('ALTER TABLE users ADD COLUMN phone TEXT');
}
const settingsColumns = db.prepare("PRAGMA table_info(settings)").all();
const hasEnvironmentModeColumn = settingsColumns.some((column) => column.name === 'environment_mode');
if (!hasEnvironmentModeColumn) {
db.exec("ALTER TABLE settings ADD COLUMN environment_mode TEXT DEFAULT 'sandbox'");
}
if (!settingsColumns.some((column) => column.name === 'message_template')) {
db.exec("ALTER TABLE settings ADD COLUMN message_template TEXT DEFAULT ''");
}
if (!settingsColumns.some((column) => column.name === 'trial_status')) {
db.exec("ALTER TABLE settings ADD COLUMN trial_status TEXT DEFAULT 'not_applied'");
}
if (!settingsColumns.some((column) => column.name === 'trial_applied_at')) {
db.exec("ALTER TABLE settings ADD COLUMN trial_applied_at DATETIME");
}
if (!settingsColumns.some((column) => column.name === 'trial_activated_at')) {
db.exec("ALTER TABLE settings ADD COLUMN trial_activated_at DATETIME");
}
if (!settingsColumns.some((column) => column.name === 'trial_bonus_credits')) {
db.exec("ALTER TABLE settings ADD COLUMN trial_bonus_credits REAL DEFAULT 0");
}
if (!settingsColumns.some((column) => column.name === 'live_bonus_credits')) {
db.exec("ALTER TABLE settings ADD COLUMN live_bonus_credits REAL DEFAULT 0");
}
const apiKeyColumns = db.prepare("PRAGMA table_info(api_keys)").all();
if (!apiKeyColumns.some((column) => column.name === 'name')) {
db.exec("ALTER TABLE api_keys ADD COLUMN name TEXT NOT NULL DEFAULT 'Default Key'");
}
if (!apiKeyColumns.some((column) => column.name === 'last_used_at')) {
db.exec("ALTER TABLE api_keys ADD COLUMN last_used_at DATETIME");
}
if (!apiKeyColumns.some((column) => column.name === 'mode')) {
db.exec("ALTER TABLE api_keys ADD COLUMN mode TEXT DEFAULT 'sandbox'");
}
const otpLogColumns = db.prepare("PRAGMA table_info(otp_logs)").all();
if (!otpLogColumns.some((column) => column.name === 'api_key_id')) {
db.exec("ALTER TABLE otp_logs ADD COLUMN api_key_id INTEGER");
} if (!otpLogColumns.some((column) => column.name === 'failed_attempts')) {
db.exec('ALTER TABLE otp_logs ADD COLUMN failed_attempts INTEGER DEFAULT 0');
}
if (!otpLogColumns.some((column) => column.name === 'resend_of')) {
db.exec('ALTER TABLE otp_logs ADD COLUMN resend_of TEXT');
}
if (!otpLogColumns.some((column) => column.name === 'quota_units')) {
db.exec('ALTER TABLE otp_logs ADD COLUMN quota_units REAL DEFAULT 1');
}
if (!otpLogColumns.some((column) => column.name === 'environment_mode')) {
db.exec("ALTER TABLE otp_logs ADD COLUMN environment_mode TEXT DEFAULT 'sandbox'");
}
if (!otpLogColumns.some((column) => column.name === 'quota_mode')) {
db.exec("ALTER TABLE otp_logs ADD COLUMN quota_mode TEXT DEFAULT 'sandbox'");
db.exec("UPDATE otp_logs SET quota_mode = COALESCE(environment_mode, 'sandbox') WHERE quota_mode IS NULL OR quota_mode = ''");
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique ON users(phone)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payment_requests_user ON payment_requests(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payment_requests_status ON payment_requests(status)');
const existingPaymentConfig = db.prepare('SELECT id FROM payment_config WHERE id = 1').get();
if (!existingPaymentConfig) {
db.prepare(`
INSERT INTO payment_config (id, payments_enabled, upi_id, upi_name, payment_note)
VALUES (1, 1, ?, ?, ?)
`).run(
(process.env.VERTIFLO_UPI_ID || '').trim(),
process.env.VERTIFLO_UPI_NAME || 'Veriflo',
'Pay via UPI and submit UTR for admin approval.'
);
}
const superAdminEmail = (process.env.VERTIFLO_SUPERADMIN_EMAIL || 'superadmin@veriflo.local').trim().toLowerCase();
const superAdminPassword = process.env.VERTIFLO_SUPERADMIN_PASSWORD || 'SuperAdmin@123';
const superAdminName = process.env.VERTIFLO_SUPERADMIN_NAME || 'Super Admin';
const existingAdmin = db.prepare('SELECT id FROM users WHERE email = ?').get(superAdminEmail);
if (!existingAdmin) {
const hash = bcrypt.hashSync(superAdminPassword, 10);
const insert = db.prepare(`
INSERT INTO users (name, company, email, password_hash, role)
VALUES (?, ?, ?, ?, 'admin')
`).run(superAdminName, 'Veriflo', superAdminEmail, hash);
db.prepare(`
INSERT INTO settings (user_id, sender_name, greeting, message_template, trial_status, trial_activated_at)
VALUES (?, ?, ?, ?, 'active', CURRENT_TIMESTAMP)
`).run(insert.lastInsertRowid, 'Veriflo', 'Hello!', 'Your OTP is {otp}.');
console.log(`Seeded super admin account: ${superAdminEmail}`);
}
console.log('SQLite Schema Ready.');
}
module.exports = {
db,
initSchema
};

68
src/middlewares/apiKey.js Normal file
View File

@ -0,0 +1,68 @@
const crypto = require('crypto');
const { db } = require('../db');
function requireApiKey(req, res, next) {
// Accept key from: Authorization header, request body, or query string
let plainApiKey = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
plainApiKey = authHeader.split(' ')[1];
} else if (req.body?.api_key) {
plainApiKey = String(req.body.api_key);
} else if (req.query?.api_key) {
plainApiKey = String(req.query.api_key);
}
if (!plainApiKey) {
return res.status(401).json({ error: 'Unauthorized: API key required. Pass it via Authorization header (Bearer <key>), request body (api_key), or query param (?api_key=).' });
}
try {
// Hash the incoming key to compare with the database
const keyHash = crypto.createHash('sha256').update(plainApiKey).digest('hex');
// Find the associated user and key record
const record = db.prepare(`
SELECT ak.id as key_id, ak.name as key_name, ak.mode as key_mode, u.id as user_id, u.company, u.name
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.key_hash = ?
`).get(keyHash);
if (!record) {
return res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
}
// Advanced request analytics capture
const requestIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const userAgent = req.headers['user-agent'] || 'Unknown';
// Update last_used_ip on the API key non-blockingly
try {
db.prepare('UPDATE api_keys SET last_used_ip = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(requestIp, record.key_id);
} catch (e) {
console.error("Failed to update last_used_ip", e);
}
// Attach verified user context to the request for the controller
req.apiKeyUser = {
id: record.user_id,
key_id: record.key_id,
key_name: record.key_name,
key_mode: record.key_mode || 'sandbox',
company: record.company,
name: record.name,
ip: requestIp,
ua: userAgent
};
next();
} catch (err) {
console.error('API Key Verification Error:', err);
return res.status(500).json({ error: 'Internal Server Error during authentication' });
}
}
module.exports = { requireApiKey };

35
src/middlewares/auth.js Normal file
View File

@ -0,0 +1,35 @@
const jwt = require('jsonwebtoken');
// Hardcoded for development, should be env var in production
const JWT_SECRET = process.env.JWT_SECRET || 'vertiflo_super_secret_key_2026';
function generateToken(user, expiresIn = '7d') {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn }
);
}
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // Attach user payload to request
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Invalid or expired token' });
}
}
module.exports = {
JWT_SECRET,
generateToken,
verifyToken
};

400
src/routes/admin.js Normal file
View File

@ -0,0 +1,400 @@
const express = require('express');
const { db } = require('../db');
const { verifyToken } = require('../middlewares/auth');
const { getSandboxMonthlyMessageLimit } = require('../utils/sandbox');
const router = express.Router();
// Super Admin Protection Middleware
function requireSuperAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden: Super Admin privileges required.' });
}
next();
}
// ==========================================
// GET /api/admin/dashboard
// Global Platform Stats
// ==========================================
router.get('/dashboard', verifyToken, requireSuperAdmin, (req, res) => {
try {
// Platform Totals
const totalUsers = db.prepare("SELECT COUNT(*) as count FROM users").get().count;
const totalOtps = db.prepare("SELECT COUNT(*) as count FROM otp_logs").get().count;
const failedOtps = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE status='failed'").get().count;
// Last 30 Days Global Usage
const thirtyDaysUsage = db.prepare(`
SELECT
date(created_at) as date,
COUNT(*) as volume
FROM otp_logs
WHERE created_at >= date('now', '-30 days')
GROUP BY date(created_at)
ORDER BY date(created_at) ASC
`).all();
res.json({
kpis: {
total_registered_users: totalUsers,
total_platform_otps: totalOtps,
global_failed_otps: failedOtps
},
chart_data: thirtyDaysUsage
});
} catch (err) {
console.error('Admin Dashboard Error:', err);
res.status(500).json({ error: 'Failed to fetch admin dashboard stats' });
}
});
// ==========================================
// GET /api/admin/users
// User Management List
// ==========================================
router.get('/users', verifyToken, requireSuperAdmin, (req, res) => {
try {
const baseTrialLimit = getSandboxMonthlyMessageLimit();
// Join users with their total OTP count
const usersList = db.prepare(`
SELECT
u.id, u.name, u.company, u.email, u.role, u.created_at,
COALESCE(s.trial_status, 'not_applied') as trial_status,
COALESCE(s.trial_bonus_credits, 0) as trial_bonus_credits,
(SELECT COUNT(*) FROM otp_logs o WHERE o.user_id = u.id) as otps_sent,
(
SELECT COALESCE(SUM(COALESCE(o.quota_units, 1)), 0)
FROM otp_logs o
WHERE o.user_id = u.id
AND COALESCE(o.quota_mode, o.environment_mode, 'sandbox') = 'sandbox'
AND o.created_at >= datetime('now', 'start of month')
) as sandbox_used_this_month
FROM users u
LEFT JOIN settings s ON s.user_id = u.id
ORDER BY u.created_at DESC
`).all();
res.json({
users: usersList.map((user) => {
const bonus = Number(user.trial_bonus_credits || 0);
const used = Number(user.sandbox_used_this_month || 0);
const total = Number((baseTrialLimit + bonus).toFixed(2));
return {
...user,
trial_base_credits: baseTrialLimit,
trial_total_credits: total,
trial_remaining_credits: Number(Math.max(0, total - used).toFixed(2)),
};
})
});
} catch (err) {
console.error('Admin Users Error:', err);
res.status(500).json({ error: 'Failed to fetch user list' });
}
});
// ==========================================
// POST /api/admin/users/:id/trial-credits
// Increase free trial credits for a user
// ==========================================
router.post('/users/:id/trial-credits', verifyToken, requireSuperAdmin, (req, res) => {
try {
const userId = Number(req.params.id);
const increment = Number(req.body?.credits);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({ error: 'Invalid user id' });
}
if (!Number.isFinite(increment) || increment <= 0) {
return res.status(400).json({ error: 'credits must be a positive number' });
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
db.prepare(`
UPDATE settings
SET trial_bonus_credits = COALESCE(trial_bonus_credits, 0) + ?
WHERE user_id = ?
`).run(increment, userId);
const updated = db.prepare('SELECT COALESCE(trial_bonus_credits, 0) as trial_bonus_credits FROM settings WHERE user_id = ?').get(userId);
const base = getSandboxMonthlyMessageLimit();
const bonus = Number(updated?.trial_bonus_credits || 0);
return res.json({
success: true,
message: `Added ${increment} trial credits successfully`,
user_id: userId,
trial_base_credits: base,
trial_bonus_credits: Number(bonus.toFixed(2)),
trial_total_credits: Number((base + bonus).toFixed(2))
});
} catch (err) {
console.error('Admin Trial Credits Error:', err);
res.status(500).json({ error: 'Failed to update trial credits' });
}
});
// ==========================================
// GET /api/admin/payment-config
// Payment methods configuration
// ==========================================
router.get('/payment-config', verifyToken, requireSuperAdmin, (req, res) => {
try {
const config = db.prepare(`
SELECT
payments_enabled,
upi_id,
upi_name,
payment_note,
updated_at
FROM payment_config
WHERE id = 1
`).get();
if (!config) {
return res.status(404).json({ error: 'Payment configuration not found' });
}
return res.json({
config: {
payments_enabled: Number(config.payments_enabled || 0) === 1,
upi_id: config.upi_id || '',
upi_name: config.upi_name || 'Veriflo',
payment_note: config.payment_note || '',
updated_at: config.updated_at || null,
}
});
} catch (err) {
console.error('Admin Payment Config Fetch Error:', err);
return res.status(500).json({ error: 'Failed to fetch payment config' });
}
});
// ==========================================
// PUT /api/admin/payment-config
// Update payment methods configuration
// ==========================================
router.put('/payment-config', verifyToken, requireSuperAdmin, (req, res) => {
try {
const paymentsEnabled = req.body?.payments_enabled === true ? 1 : 0;
const upiId = String(req.body?.upi_id || '').trim();
const upiName = String(req.body?.upi_name || 'Veriflo').trim();
const paymentNote = String(req.body?.payment_note || '').trim();
if (paymentsEnabled === 1 && !upiId) {
return res.status(400).json({ error: 'UPI ID is required when payments are enabled' });
}
db.prepare(`
UPDATE payment_config
SET payments_enabled = ?,
upi_id = ?,
upi_name = ?,
payment_note = ?,
updated_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`).run(paymentsEnabled, upiId, upiName || 'Veriflo', paymentNote, req.user.id);
return res.json({
success: true,
message: 'Payment configuration saved',
config: {
payments_enabled: paymentsEnabled === 1,
upi_id: upiId,
upi_name: upiName || 'Veriflo',
payment_note: paymentNote,
}
});
} catch (err) {
console.error('Admin Payment Config Update Error:', err);
return res.status(500).json({ error: 'Failed to update payment config' });
}
});
// ==========================================
// GET /api/admin/payments
// Payment approvals queue
// ==========================================
router.get('/payments', verifyToken, requireSuperAdmin, (req, res) => {
try {
const status = String(req.query.status || 'all').trim().toLowerCase();
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 10), 300);
let rows;
if (status === 'all') {
rows = db.prepare(`
SELECT
p.id,
p.request_ref,
p.package_name,
p.credits,
p.amount_inr,
p.utr,
p.status,
p.admin_note,
p.created_at,
p.updated_at,
p.approved_at,
u.id as user_id,
u.name as user_name,
u.email as user_email
FROM payment_requests p
JOIN users u ON u.id = p.user_id
ORDER BY p.created_at DESC
LIMIT ?
`).all(limit);
} else {
rows = db.prepare(`
SELECT
p.id,
p.request_ref,
p.package_name,
p.credits,
p.amount_inr,
p.utr,
p.status,
p.admin_note,
p.created_at,
p.updated_at,
p.approved_at,
u.id as user_id,
u.name as user_name,
u.email as user_email
FROM payment_requests p
JOIN users u ON u.id = p.user_id
WHERE p.status = ?
ORDER BY p.created_at DESC
LIMIT ?
`).all(status, limit);
}
res.json({ payments: rows });
} catch (err) {
console.error('Admin Payments Error:', err);
res.status(500).json({ error: 'Failed to fetch payments' });
}
});
// ==========================================
// POST /api/admin/payments/:id/approve
// Approve payment and credit live bonus bucket
// ==========================================
router.post('/payments/:id/approve', verifyToken, requireSuperAdmin, (req, res) => {
try {
const id = Number(req.params.id);
const adminNote = String(req.body?.admin_note || '').trim();
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid payment id' });
const payment = db.prepare('SELECT id, user_id, credits, status FROM payment_requests WHERE id = ?').get(id);
if (!payment) return res.status(404).json({ error: 'Payment not found' });
if (payment.status === 'approved') return res.status(400).json({ error: 'Payment already approved' });
if (payment.status === 'rejected') return res.status(400).json({ error: 'Rejected payment cannot be approved' });
const tx = db.transaction(() => {
db.prepare(`
UPDATE payment_requests
SET status = 'approved',
admin_note = ?,
approved_by = ?,
approved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(adminNote || null, req.user.id, id);
db.prepare(`
UPDATE settings
SET live_bonus_credits = COALESCE(live_bonus_credits, 0) + ?
WHERE user_id = ?
`).run(payment.credits, payment.user_id);
});
tx();
res.json({ success: true, message: 'Payment approved and credits added' });
} catch (err) {
console.error('Approve Payment Error:', err);
res.status(500).json({ error: 'Failed to approve payment' });
}
});
// ==========================================
// POST /api/admin/payments/:id/reject
// ==========================================
router.post('/payments/:id/reject', verifyToken, requireSuperAdmin, (req, res) => {
try {
const id = Number(req.params.id);
const adminNote = String(req.body?.admin_note || '').trim();
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid payment id' });
const payment = db.prepare('SELECT id, status FROM payment_requests WHERE id = ?').get(id);
if (!payment) return res.status(404).json({ error: 'Payment not found' });
if (payment.status === 'approved') return res.status(400).json({ error: 'Approved payment cannot be rejected' });
db.prepare(`
UPDATE payment_requests
SET status = 'rejected',
admin_note = ?,
approved_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(adminNote || null, req.user.id, id);
res.json({ success: true, message: 'Payment rejected' });
} catch (err) {
console.error('Reject Payment Error:', err);
res.status(500).json({ error: 'Failed to reject payment' });
}
});
// ==========================================
// GET /api/admin/logs
// Global Trace Viewer
// ==========================================
router.get('/logs', verifyToken, requireSuperAdmin, (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const logs = db.prepare(`
SELECT
o.request_id, o.phone, o.status, o.ip_address, o.created_at,
u.name as user_name, u.company as user_company
FROM otp_logs o
JOIN users u ON o.user_id = u.id
ORDER BY o.created_at DESC
LIMIT ?
`).all(limit);
res.json({ logs });
} catch (err) {
console.error('Admin Logs Error:', err);
res.status(500).json({ error: 'Failed to fetch global logs' });
}
});
// ==========================================
// GET /api/admin/whatsapp/status
// Check local puppeteer session status
// ==========================================
router.get('/whatsapp/status', verifyToken, requireSuperAdmin, (req, res) => {
try {
res.json({
connected: req.app.locals.waReady,
uptime: process.uptime(), // seconds
memory: process.memoryUsage()
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch system status' });
}
});
module.exports = router;

290
src/routes/analytics.js Normal file
View File

@ -0,0 +1,290 @@
const express = require('express');
const { db } = require('../db');
const { verifyToken } = require('../middlewares/auth');
const router = express.Router();
const OTP_COST_INR = 0.2;
// ==========================================
// GET /api/user/analytics/summary
// Summary for Dashboard Home & Analytics
// ==========================================
router.get('/summary', verifyToken, (req, res) => {
try {
const userId = req.user.id;
const settings = db.prepare('SELECT environment_mode, trial_status, trial_applied_at, trial_activated_at FROM settings WHERE user_id = ?').get(userId);
const user = db.prepare('SELECT created_at FROM users WHERE id = ?').get(userId);
// Get lifetime stats
const totalSent = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status != 'pending'").get(userId).count;
const totalDelivered = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status IN ('sent', 'verified', 'expired')").get(userId).count;
const totalFailed = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status = 'failed'").get(userId).count;
const totalVerified = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status = 'verified'").get(userId).count;
const totalExpired = db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status = 'expired'").get(userId).count;
const totalFailedAttempts = db.prepare("SELECT COALESCE(SUM(COALESCE(failed_attempts, 0)), 0) as count FROM otp_logs WHERE user_id = ?").get(userId).count;
const uniqueRecipients = db.prepare("SELECT COUNT(DISTINCT phone) as count FROM otp_logs WHERE user_id = ?").get(userId).count;
// Delivery rate
const deliveryRate = totalSent > 0 ? ((totalDelivered / totalSent) * 100).toFixed(1) : 0;
const verificationRate = totalSent > 0 ? ((totalVerified / totalSent) * 100).toFixed(1) : 0;
// Daily breakdown for the last 7 days for the Charts
const dailyStats = db.prepare(`
SELECT
date(created_at) as date,
COUNT(*) as total,
SUM(CASE WHEN status IN ('sent', 'verified', 'expired') THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
FROM otp_logs
WHERE user_id = ? AND created_at >= date('now', '-7 days')
GROUP BY date(created_at)
ORDER BY date(created_at) ASC
`).all(userId);
// Get average latency
const latencyAvg = db.prepare(`
SELECT AVG(delivery_latency_ms) as avg
FROM otp_logs
WHERE user_id = ? AND delivery_latency_ms IS NOT NULL
`).get(userId).avg;
const latencyWindow = db.prepare(`
SELECT
MIN(delivery_latency_ms) as min,
MAX(delivery_latency_ms) as max,
AVG(delivery_latency_ms) as avg
FROM otp_logs
WHERE user_id = ? AND delivery_latency_ms IS NOT NULL
`).get(userId);
const recent24h = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status IN ('sent', 'verified', 'expired') THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'verified' THEN 1 ELSE 0 END) as verified,
SUM(CASE WHEN status IN ('failed', 'failed_attempt') THEN 1 ELSE 0 END) as failed
FROM otp_logs
WHERE user_id = ? AND created_at >= datetime('now', '-1 day')
`).get(userId);
const recent7d = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status IN ('sent', 'verified', 'expired') THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'verified' THEN 1 ELSE 0 END) as verified,
SUM(CASE WHEN status IN ('failed', 'failed_attempt') THEN 1 ELSE 0 END) as failed
FROM otp_logs
WHERE user_id = ? AND created_at >= datetime('now', '-7 days')
`).get(userId);
const recent30d = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status IN ('sent', 'verified', 'expired') THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'verified' THEN 1 ELSE 0 END) as verified,
SUM(CASE WHEN status IN ('failed', 'failed_attempt') THEN 1 ELSE 0 END) as failed
FROM otp_logs
WHERE user_id = ? AND created_at >= datetime('now', '-30 days')
`).get(userId);
const topDay = db.prepare(`
SELECT date(created_at) as date, COUNT(*) as total
FROM otp_logs
WHERE user_id = ?
GROUP BY date(created_at)
ORDER BY total DESC, date DESC
LIMIT 1
`).get(userId);
const latestRequest = db.prepare(`
SELECT request_id, phone, status, created_at
FROM otp_logs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 1
`).get(userId);
const activeKeys = db.prepare(`
SELECT COUNT(*) as count
FROM api_keys
WHERE user_id = ?
`).get(userId).count;
const topKey = db.prepare(`
SELECT ak.name, COUNT(o.id) as total
FROM api_keys ak
LEFT JOIN otp_logs o ON o.api_key_id = ak.id
WHERE ak.user_id = ?
GROUP BY ak.id, ak.name
ORDER BY total DESC, ak.created_at DESC
LIMIT 1
`).get(userId);
const recentActivity = db.prepare(`
SELECT type, title, meta, created_at
FROM activity_logs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 8
`).all(userId);
res.json({
lifetime: {
total_sent: totalSent,
total_delivered: totalDelivered,
total_failed: totalFailed,
total_verified: totalVerified,
total_expired: totalExpired,
total_failed_attempts: totalFailedAttempts,
unique_recipients: uniqueRecipients,
delivery_rate: `${deliveryRate}%`,
verification_rate: `${verificationRate}%`,
avg_latency_ms: latencyAvg ? Math.round(latencyAvg) : 0
},
operational: {
active_keys: activeKeys,
estimated_cost_inr: Number((totalSent * OTP_COST_INR).toFixed(2)),
top_day: topDay || null,
top_key: topKey?.name ? topKey : null,
latest_request: latestRequest || null,
latency: {
min_ms: latencyWindow?.min ? Math.round(latencyWindow.min) : 0,
avg_ms: latencyWindow?.avg ? Math.round(latencyWindow.avg) : 0,
max_ms: latencyWindow?.max ? Math.round(latencyWindow.max) : 0,
}
},
plan: {
name: (settings?.trial_status || 'not_applied') === 'active' ? 'Free Trial' : 'Trial Required',
mode: settings?.environment_mode || 'sandbox',
label: (settings?.trial_status || 'not_applied') === 'active'
? `${settings?.environment_mode === 'live' ? 'Live' : 'Sandbox'} / Free Trial`
: 'Free Trial Not Activated',
started_at: user?.created_at || null,
active: (settings?.trial_status || 'not_applied') === 'active',
trial_status: settings?.trial_status || 'not_applied',
trial_applied_at: settings?.trial_applied_at || null,
trial_activated_at: settings?.trial_activated_at || null,
},
windows: {
last_24h: {
total: recent24h.total || 0,
delivered: recent24h.delivered || 0,
verified: recent24h.verified || 0,
failed: recent24h.failed || 0,
},
last_7d: {
total: recent7d.total || 0,
delivered: recent7d.delivered || 0,
verified: recent7d.verified || 0,
failed: recent7d.failed || 0,
},
last_30d: {
total: recent30d.total || 0,
delivered: recent30d.delivered || 0,
verified: recent30d.verified || 0,
failed: recent30d.failed || 0,
}
},
chart_data: dailyStats,
recent_activity: recentActivity
});
} catch (err) {
console.error('Analytics Data Error:', err);
res.status(500).json({ error: 'Failed to aggregate analytics data' });
}
});
// ==========================================
// GET /api/user/analytics/logs
// Raw logs for the table
// ==========================================
router.get('/logs', verifyToken, (req, res) => {
try {
const userId = req.user.id;
const pageSizeRaw = parseInt(req.query.page_size, 10);
const pageRaw = parseInt(req.query.page, 10);
const pageSize = Number.isFinite(pageSizeRaw) ? Math.min(Math.max(pageSizeRaw, 5), 100) : 20;
const page = Number.isFinite(pageRaw) ? Math.max(pageRaw, 1) : 1;
const offset = (page - 1) * pageSize;
const total = db.prepare(`
SELECT COUNT(*) as count
FROM otp_logs
WHERE user_id = ?
`).get(userId).count;
const logs = db.prepare(`
SELECT request_id, phone, status, delivery_latency_ms, created_at, expires_at
FROM otp_logs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
OFFSET ?
`).all(userId, pageSize, offset);
res.json({
logs,
pagination: {
total,
page,
page_size: pageSize,
total_pages: Math.max(1, Math.ceil(total / pageSize)),
has_prev: page > 1,
has_next: page * pageSize < total,
}
});
} catch (err) {
console.error('Logs fetch Error:', err);
res.status(500).json({ error: 'Failed to fetch logs' });
}
});
// ==========================================
// GET /api/user/analytics/activity
// Activity logs history for overview modal
// ==========================================
router.get('/activity', verifyToken, (req, res) => {
try {
const userId = req.user.id;
const pageSizeRaw = parseInt(req.query.page_size, 10);
const pageRaw = parseInt(req.query.page, 10);
const pageSize = Number.isFinite(pageSizeRaw) ? Math.min(Math.max(pageSizeRaw, 5), 100) : 20;
const page = Number.isFinite(pageRaw) ? Math.max(pageRaw, 1) : 1;
const offset = (page - 1) * pageSize;
const total = db.prepare(`
SELECT COUNT(*) as count
FROM activity_logs
WHERE user_id = ?
`).get(userId).count;
const activity = db.prepare(`
SELECT type, title, meta, created_at
FROM activity_logs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
OFFSET ?
`).all(userId, pageSize, offset);
res.json({
activity,
pagination: {
total,
page,
page_size: pageSize,
total_pages: Math.max(1, Math.ceil(total / pageSize)),
has_prev: page > 1,
has_next: page * pageSize < total,
}
});
} catch (err) {
console.error('Activity fetch Error:', err);
res.status(500).json({ error: 'Failed to fetch activity logs' });
}
});
module.exports = router;

375
src/routes/auth.js Normal file
View File

@ -0,0 +1,375 @@
const express = require('express');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { db } = require('../db');
const { generateToken } = require('../middlewares/auth');
const { logActivity } = require('../utils/activity');
const { DEFAULT_TEMPLATE } = require('../utils/messageTemplate');
const { resolveWhatsAppChatId } = require('../utils/whatsapp');
const router = express.Router();
const signupOtpLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: 'Too many signup OTP requests. Please wait a minute and try again.' }
});
const passwordResetLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: 'Too many password reset OTP requests. Please wait a minute and try again.' }
});
function sanitizePhone(phone) {
return String(phone || '').replace(/\D/g, '');
}
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function generateOTP(length = 6) {
const digits = '0123456789';
let otp = '';
for (let i = 0; i < length; i++) {
otp += digits[crypto.randomInt(0, 10)];
}
return otp;
}
router.post('/signup/send-otp', signupOtpLimiter, async (req, res) => {
const waReady = req.app.locals.waReady;
const waClient = req.app.locals.waClient;
const sanitizedPhone = sanitizePhone(req.body.phone);
if (!waReady) {
return res.status(503).json({ error: 'WhatsApp delivery engine is offline right now. Try again shortly.' });
}
if (sanitizedPhone.length < 7 || sanitizedPhone.length > 15) {
return res.status(400).json({ error: 'Enter a valid WhatsApp number' });
}
try {
const existingUser = db.prepare('SELECT id FROM users WHERE phone = ?').get(sanitizedPhone);
if (existingUser) {
return res.status(409).json({ error: 'This WhatsApp number is already registered' });
}
db.prepare("UPDATE signup_otps SET status = 'superseded' WHERE phone = ? AND status = 'sent'").run(sanitizedPhone);
const requestId = `signup_${crypto.randomBytes(8).toString('hex')}`;
const otp = generateOTP(6);
const otpHash = await bcrypt.hash(otp, 8);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const recipient = await resolveWhatsAppChatId(waClient, sanitizedPhone);
if (!recipient.ok) {
return res.status(400).json({ error: recipient.error, code: recipient.code });
}
const chatId = recipient.chatId;
const textMsg = `*Veriflo verification*\n\nThis is exactly how your users will receive OTPs from Veriflo on WhatsApp.\n\nYour signup code is: *${otp}*\n\nEnter this code on the signup screen to verify your number. This code expires in 5 minutes.`;
await waClient.sendMessage(chatId, textMsg);
db.prepare(`
INSERT INTO signup_otps (request_id, phone, otp_hash, expires_at, status)
VALUES (?, ?, ?, ?, 'sent')
`).run(requestId, sanitizedPhone, otpHash, expiresAt);
res.status(200).json({
success: true,
request_id: requestId,
expires_at: expiresAt,
message: 'OTP sent to WhatsApp'
});
} catch (err) {
console.error('Signup Send OTP Error:', err);
res.status(500).json({ error: 'Could not send signup OTP' });
}
});
router.post('/signup/verify', async (req, res) => {
const { request_id, otp, name, company, password, phone } = req.body;
const email = normalizeEmail(req.body.email);
if (!request_id || !otp || !name || !company || !email || !password || !phone) {
return res.status(400).json({ error: 'Phone, OTP, name, occupation, email, and password are required' });
}
const sanitizedPhone = sanitizePhone(phone);
try {
const pendingSignup = db.prepare(`
SELECT id, phone, otp_hash, status, expires_at
FROM signup_otps
WHERE request_id = ?
`).get(request_id);
if (!pendingSignup) {
return res.status(404).json({ error: 'Signup OTP request not found' });
}
if (pendingSignup.phone !== sanitizedPhone) {
return res.status(400).json({ error: 'This OTP does not belong to that WhatsApp number' });
}
if (pendingSignup.status === 'verified') {
return res.status(400).json({ error: 'This signup OTP has already been used' });
}
if (pendingSignup.status !== 'sent') {
return res.status(400).json({ error: `This signup OTP cannot be verified (${pendingSignup.status})` });
}
if (new Date().toISOString() > pendingSignup.expires_at) {
db.prepare("UPDATE signup_otps SET status = 'expired' WHERE id = ?").run(pendingSignup.id);
return res.status(400).json({ error: 'This OTP has expired' });
}
const isMatch = await bcrypt.compare(String(otp), pendingSignup.otp_hash);
if (!isMatch) {
return res.status(400).json({ error: 'Invalid OTP code' });
}
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingEmail) {
return res.status(409).json({ error: 'Email already registered' });
}
const existingPhone = db.prepare('SELECT id FROM users WHERE phone = ?').get(sanitizedPhone);
if (existingPhone) {
return res.status(409).json({ error: 'This WhatsApp number is already registered' });
}
const passwordHash = await bcrypt.hash(password, 10);
const insertUser = db.prepare(
'INSERT INTO users (name, company, email, phone, password_hash) VALUES (?, ?, ?, ?, ?)'
);
const result = insertUser.run(name, company, email, sanitizedPhone, passwordHash);
const userId = result.lastInsertRowid;
db.prepare(
'INSERT INTO settings (user_id, sender_name, greeting, message_template) VALUES (?, ?, ?, ?)'
).run(userId, company || name, 'Hello!', DEFAULT_TEMPLATE);
db.prepare(`
UPDATE signup_otps
SET status = 'verified', name = ?, company = ?, email = ?, password_hash = ?
WHERE id = ?
`).run(name, company, email, passwordHash, pendingSignup.id);
const user = { id: userId, email, role: 'user' };
const token = generateToken(user);
logActivity(userId, 'signup_completed', 'Account created', 'WhatsApp OTP signup completed');
res.status(201).json({
message: 'Account created successfully',
token,
user: { id: userId, name, email, company, phone: sanitizedPhone, role: 'user' }
});
} catch (err) {
console.error('Signup Verify Error:', err);
res.status(500).json({ error: 'Could not verify OTP and create account' });
}
});
router.post('/password-reset/send-otp', passwordResetLimiter, async (req, res) => {
const waReady = req.app.locals.waReady;
const waClient = req.app.locals.waClient;
const email = normalizeEmail(req.body.email);
const sanitizedPhone = sanitizePhone(req.body.phone);
if (!email || !sanitizedPhone) {
return res.status(400).json({ error: 'Email and WhatsApp number are required' });
}
if (!waReady) {
return res.status(503).json({ error: 'WhatsApp delivery engine is offline right now. Try again shortly.' });
}
try {
const user = db.prepare('SELECT id, email, phone FROM users WHERE email = ?').get(email);
if (!user || user.phone !== sanitizedPhone) {
return res.status(404).json({ error: 'No account matched that email and WhatsApp number' });
}
db.prepare("UPDATE password_reset_otps SET status = 'superseded' WHERE user_id = ? AND status = 'sent'").run(user.id);
const requestId = `reset_${crypto.randomBytes(8).toString('hex')}`;
const otp = generateOTP(6);
const otpHash = await bcrypt.hash(otp, 8);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const recipient = await resolveWhatsAppChatId(waClient, sanitizedPhone);
if (!recipient.ok) {
return res.status(400).json({ error: recipient.error, code: recipient.code });
}
const chatId = recipient.chatId;
const textMsg = `*Veriflo password reset*\n\nWe confirmed your account email as *${email}*.\n\nYour password reset OTP is: *${otp}*\n\nEnter this code on the reset screen to continue. This code expires in 5 minutes.`;
await waClient.sendMessage(chatId, textMsg);
db.prepare(`
INSERT INTO password_reset_otps (request_id, user_id, phone, email, otp_hash, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, 'sent')
`).run(requestId, user.id, sanitizedPhone, email, otpHash, expiresAt);
res.json({
success: true,
request_id: requestId,
expires_at: expiresAt,
message: 'Password reset OTP sent to WhatsApp'
});
} catch (err) {
console.error('Password Reset Send OTP Error:', err);
res.status(500).json({ error: 'Could not send password reset OTP' });
}
});
router.post('/password-reset/verify', async (req, res) => {
const { request_id, email, phone, otp, password } = req.body;
const sanitizedPhone = sanitizePhone(phone);
const normalizedEmail = normalizeEmail(email);
if (!request_id || !normalizedEmail || !sanitizedPhone || !otp || !password) {
return res.status(400).json({ error: 'Email, WhatsApp number, OTP, and new password are required' });
}
try {
const resetRequest = db.prepare(`
SELECT id, user_id, phone, email, otp_hash, status, expires_at
FROM password_reset_otps
WHERE request_id = ?
`).get(request_id);
if (!resetRequest) {
return res.status(404).json({ error: 'Password reset request not found' });
}
if (resetRequest.phone !== sanitizedPhone || resetRequest.email !== normalizedEmail) {
return res.status(400).json({ error: 'The email or WhatsApp number does not match this reset request' });
}
if (resetRequest.status === 'verified') {
return res.status(400).json({ error: 'This password reset OTP has already been used' });
}
if (resetRequest.status !== 'sent') {
return res.status(400).json({ error: `This password reset OTP cannot be verified (${resetRequest.status})` });
}
if (new Date().toISOString() > resetRequest.expires_at) {
db.prepare("UPDATE password_reset_otps SET status = 'expired' WHERE id = ?").run(resetRequest.id);
return res.status(400).json({ error: 'This OTP has expired' });
}
const isMatch = await bcrypt.compare(String(otp), resetRequest.otp_hash);
if (!isMatch) {
return res.status(400).json({ error: 'Invalid OTP code' });
}
const passwordHash = await bcrypt.hash(password, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, resetRequest.user_id);
db.prepare("UPDATE password_reset_otps SET status = 'verified' WHERE id = ?").run(resetRequest.id);
res.json({
success: true,
message: 'Password updated successfully'
});
} catch (err) {
console.error('Password Reset Verify Error:', err);
res.status(500).json({ error: 'Could not verify OTP and update password' });
}
});
// User Registration
router.post('/signup', async (req, res) => {
const { name, company, password } = req.body;
const email = normalizeEmail(req.body.email);
if (!name || !email || !password) {
return res.status(400).json({ error: 'Name, email, and password are required' });
}
try {
// Check if user exists
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Insert user
const insertUser = db.prepare(
'INSERT INTO users (name, company, email, password_hash) VALUES (?, ?, ?, ?)'
);
const result = insertUser.run(name, company || '', email, passwordHash);
const userId = result.lastInsertRowid;
// Initialize default settings for user
const initSettings = db.prepare(
`INSERT INTO settings (user_id, sender_name, greeting, message_template) VALUES (?, ?, ?, ?)`
);
initSettings.run(userId, company || name, 'Hello!', DEFAULT_TEMPLATE);
// Generate Token
const user = { id: userId, email, role: 'user' };
const token = generateToken(user);
res.status(201).json({
message: 'Account created successfully',
token,
user: { id: userId, name, email, company }
});
} catch (err) {
console.error('Signup Error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// User Login
router.post('/login', async (req, res) => {
const { password } = req.body;
const rememberMe = req.body.remember_me === true;
const email = normalizeEmail(req.body.email);
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
try {
// Find user
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Check password
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Generate Token
const token = generateToken(user, rememberMe ? '3d' : '1d');
res.json({
message: 'Login successful',
token,
user: { id: user.id, name: user.name, email: user.email, company: user.company, role: user.role }
});
logActivity(user.id, 'login', 'Logged in', 'Dashboard session started');
} catch (err) {
console.error('Login Error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

570
src/routes/otp.js Normal file
View File

@ -0,0 +1,570 @@
const express = require('express');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { db } = require('../db');
const { requireApiKey } = require('../middlewares/apiKey');
const { logActivity } = require('../utils/activity');
const { postWebhook } = require('../utils/webhooks');
const { DEFAULT_TEMPLATE, renderMessageTemplate } = require('../utils/messageTemplate');
const {
getUserSandboxMonthlyMessageLimit,
getUserLiveMonthlyMessageLimit,
getUserLiveBonusCredits,
getSandboxIntegrationQuotaUnits,
getSandboxUsageThisMonth,
getLiveUsageThisMonth
} = require('../utils/sandbox');
const { resolveWhatsAppChatId } = require('../utils/whatsapp');
const router = express.Router();
function getRateLimitIpKey(req) {
return ipKeyGenerator(req.ip || req.socket?.remoteAddress || '0.0.0.0');
}
function isIntegrationTestRequest(req) {
const headerValue = String(req.get('x-veriflo-test') || '').toLowerCase();
return req.body?.integration_test === true || req.body?.test_mode === true || headerValue === '1' || headerValue === 'true';
}
// ── Rate Limiters ─────────────────────────────────────────────────────────
// Send: per-IP — 100 req/min (blocks IP-level flood)
const sendIpLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
keyGenerator: (req) => getRateLimitIpKey(req),
message: { error: 'Rate limit exceeded: too many OTP send requests from this IP. Try again in a minute.', code: 'RATE_LIMIT_IP' },
});
// Send: per-user/API-key — 100 req/min (prevents one account flooding)
const sendUserLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
keyGenerator: (req) => req.apiKeyUser?.id ? `user_send_${req.apiKeyUser.id}` : `ip_send_${getRateLimitIpKey(req)}`,
message: { error: 'Rate limit exceeded: max 100 OTP send requests per minute per account.', code: 'RATE_LIMIT_USER' },
});
// Verify: per-IP — 120 req/min (generous for verify)
const verifyIpLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
keyGenerator: (req) => getRateLimitIpKey(req),
message: { error: 'Rate limit exceeded: too many verification requests from this IP. Try again in a minute.', code: 'RATE_LIMIT_IP' },
});
// Verify: per request_id — 10 attempts / 15 min (brute-force OTP protection)
const verifyRequestLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => `verify_rid_${req.body?.request_id || getRateLimitIpKey(req)}`,
message: { error: 'Too many verification attempts for this OTP. Request a new OTP.', code: 'RATE_LIMIT_OTP_ATTEMPTS' },
});
// Resend: per-user — 10 req/min (prevents resend spam per account)
const resendUserLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
keyGenerator: (req) => req.apiKeyUser?.id ? `user_resend_${req.apiKeyUser.id}` : `ip_resend_${getRateLimitIpKey(req)}`,
message: { error: 'Rate limit exceeded: too many resend requests. Try again in a minute.', code: 'RATE_LIMIT_RESEND' },
});
// Helper to generate N digits
function generateOTP(length) {
const digits = '0123456789';
let otp = '';
for (let i = 0; i < length; i++) {
otp += digits[crypto.randomInt(0, 10)];
}
return otp;
}
function resolveQuotaMode({ userId, environmentMode, quotaUnits }) {
const mode = environmentMode === 'live' ? 'live' : 'sandbox';
if (mode === 'live') {
return 'live';
}
// Priority rule: if user has purchased/live credits, consume live bucket first.
const liveBonusCredits = getUserLiveBonusCredits(userId);
if (liveBonusCredits > 0) {
const liveUsed = getLiveUsageThisMonth(userId);
const liveLimit = getUserLiveMonthlyMessageLimit(userId);
if (liveUsed + quotaUnits <= liveLimit) {
return 'live';
}
}
return 'sandbox';
}
// ==========================================
// POST /v1/otp/send (PUBLIC SDK ENDPOINT)
// ==========================================
router.post('/send', requireApiKey, sendIpLimiter, sendUserLimiter, async (req, res) => {
const startTime = Date.now();
const waReady = req.app.locals.waReady;
const waClient = req.app.locals.waClient;
if (!waReady) {
return res.status(503).json({ error: 'WhatsApp delivery engine is officially offline. Try again later.' });
}
const { phone, otp: customOtp } = req.body;
if (!phone) {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
// Validate custom OTP if provided
if (customOtp !== undefined) {
const otpStr = String(customOtp);
if (!/^\d{4,16}$/.test(otpStr)) {
return res.status(400).json({ error: 'Custom otp must be 416 digits (numeric only)', code: 'INVALID_CUSTOM_OTP' });
}
}
const userId = req.apiKeyUser.id;
const keyMode = req.apiKeyUser.key_mode === 'live' ? 'live' : 'sandbox';
try {
// 1. Fetch user settings
const settings = db.prepare('SELECT sender_name, greeting, message_template, otp_length, expiry_seconds, webhook_url, return_otp_in_response FROM settings WHERE user_id = ?').get(userId);
const user = db.prepare('SELECT phone FROM users WHERE id = ?').get(userId);
const isIntegrationTest = isIntegrationTestRequest(req);
const quotaUnits = keyMode === 'sandbox' && isIntegrationTest
? getSandboxIntegrationQuotaUnits()
: 1;
const quotaMode = resolveQuotaMode({
userId,
environmentMode: keyMode,
quotaUnits,
});
// 2. Format inputs
const sanitizedPhone = String(phone).replace(/\D/g, "");
if (sanitizedPhone.length < 7 || sanitizedPhone.length > 15) {
return res.status(400).json({ error: "Invalid phone number format" });
}
if (keyMode === 'sandbox' && user?.phone && sanitizedPhone !== user.phone) {
return res.status(403).json({
error: 'Sandbox mode only allows OTP delivery to your registered WhatsApp number',
code: 'SANDBOX_RECIPIENT_RESTRICTED',
allowed_phone: user.phone,
environment_mode: 'sandbox'
});
}
if (quotaMode === 'sandbox') {
const usedThisMonth = getSandboxUsageThisMonth(userId);
const monthlyLimit = getUserSandboxMonthlyMessageLimit(userId);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Sandbox mode monthly message limit reached',
code: 'SANDBOX_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits,
environment_mode: 'sandbox'
});
}
}
if (quotaMode === 'live') {
const usedThisMonth = getLiveUsageThisMonth(userId);
const monthlyLimit = getUserLiveMonthlyMessageLimit(userId);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Live mode monthly message limit reached',
code: 'LIVE_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits,
environment_mode: 'live'
});
}
}
const recipient = await resolveWhatsAppChatId(waClient, sanitizedPhone);
if (!recipient.ok) {
await postWebhook(settings.webhook_url, {
event: 'otp.delivery_failed',
request_id: null,
phone: sanitizedPhone,
status: 'failed',
environment_mode: keyMode,
provider: 'whatsapp',
error: recipient.error,
code: recipient.code,
timestamp: new Date().toISOString(),
});
return res.status(400).json({ error: recipient.error, code: recipient.code });
}
const chatId = recipient.chatId;
const requestID = `req_${crypto.randomBytes(8).toString('hex')}`;
const otpValue = customOtp !== undefined ? String(customOtp) : generateOTP(settings.otp_length);
const expiresAt = new Date(Date.now() + settings.expiry_seconds * 1000).toISOString();
// Securely hash OTP for verification endpoint later
const otpHash = await bcrypt.hash(otpValue, 8);
// 3. Construct Message
const textMsg = renderMessageTemplate(settings.message_template || DEFAULT_TEMPLATE, {
greeting: settings.greeting || 'Hello!',
sender_name: settings.sender_name || 'Veriflo',
otp: otpValue,
expiry_seconds: settings.expiry_seconds,
});
// 4. Send via WhatsApp
try {
await waClient.sendMessage(chatId, textMsg);
} catch (waError) {
console.error("WA Send Fail:", waError);
await postWebhook(settings.webhook_url, {
event: 'otp.delivery_failed',
request_id: requestID,
phone: sanitizedPhone,
status: 'failed',
environment_mode: keyMode,
provider: 'whatsapp',
error: 'WhatsApp routing failed',
timestamp: new Date().toISOString(),
});
return res.status(500).json({ error: 'WhatsApp routing failed' });
}
const latency = Date.now() - startTime;
// 5. Log transaction into SQLite Analytics Database
db.prepare(`
INSERT INTO otp_logs (request_id, user_id, api_key_id, phone, otp_hash, status, expires_at, ip_address, user_agent, delivery_latency_ms, quota_units, environment_mode, quota_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
requestID, userId, req.apiKeyUser.key_id, sanitizedPhone,
settings.return_otp_in_response ? otpValue : otpHash, // Store raw if requested for deep debug
'sent', expiresAt,
req.apiKeyUser.ip, req.apiKeyUser.ua, latency, quotaUnits, keyMode, quotaMode
);
// 6. Return standard success payload
const payload = {
success: true,
request_id: requestID,
expires_at: expiresAt,
message: 'OTP Sent successfully to WhatsApp',
quota_units: quotaUnits,
quota_mode: quotaMode,
};
// If super admin analytics debug flag enabled, echo back
if (settings.return_otp_in_response) {
payload.debug_otp = otpValue; // Feature explicitly requested by user in checkpoint
}
logActivity(userId, 'otp_sent', 'OTP sent', `Recipient: ${sanitizedPhone}`);
await postWebhook(settings.webhook_url, {
event: 'otp.delivered',
request_id: requestID,
phone: sanitizedPhone,
status: 'sent',
environment_mode: keyMode,
provider: 'whatsapp',
expires_at: expiresAt,
delivery_latency_ms: latency,
timestamp: new Date().toISOString(),
});
res.status(200).json(payload);
} catch (err) {
console.error('Send OTP Endpoint Error:', err);
res.status(500).json({ error: 'Internal API Server Error' });
}
});
// ==========================================
// POST /v1/otp/verify (PUBLIC SDK ENDPOINT)
// ==========================================
router.post('/verify', requireApiKey, verifyIpLimiter, verifyRequestLimiter, async (req, res) => {
const { request_id, otp } = req.body;
const userId = req.apiKeyUser.id;
const keyMode = req.apiKeyUser.key_mode === 'live' ? 'live' : 'sandbox';
if (!request_id || !otp) {
return res.status(400).json({ error: 'request_id and otp are required' });
}
try {
// 1. Fetch the log, including failed_attempts counter
const log = db.prepare(
'SELECT id, otp_hash, status, expires_at, failed_attempts, environment_mode FROM otp_logs WHERE request_id = ? AND user_id = ?'
).get(request_id, userId);
if (!log) {
return res.status(404).json({ error: 'OTP request not found or does not belong to your account' });
}
if ((log.environment_mode || 'sandbox') !== keyMode) {
return res.status(403).json({
error: `This request belongs to ${log.environment_mode || 'sandbox'} mode. Use a ${log.environment_mode || 'sandbox'} key.`,
code: 'OTP_MODE_MISMATCH',
});
}
if (log.status === 'verified') {
return res.status(400).json({ error: 'This OTP has already been verified previously', code: 'ALREADY_VERIFIED' });
}
if (log.status === 'failed') {
return res.status(400).json({ error: 'This OTP has been invalidated due to too many failed attempts. Request a new OTP.', code: 'OTP_INVALIDATED' });
}
if (log.status === 'superseded') {
return res.status(400).json({ error: 'This OTP was superseded by a resend. Use the newest request_id.', code: 'OTP_SUPERSEDED' });
}
// Allow 'sent' and legacy 'failed_attempt' statuses through to the OTP check
if (log.status === 'expired') {
return res.status(400).json({ error: 'This OTP has expired', expired: true, code: 'OTP_EXPIRED' });
}
// 2. Check Expiry Time against strict UTC
const now = new Date().toISOString();
if (now > log.expires_at) {
db.prepare("UPDATE otp_logs SET status = 'expired' WHERE id = ?").run(log.id);
return res.status(400).json({ error: 'This OTP has expired', expired: true, code: 'OTP_EXPIRED' });
}
const failedAttempts = log.failed_attempts || 0;
// 3. Guard: lock out after 5 failed attempts
if (failedAttempts >= 5) {
db.prepare("UPDATE otp_logs SET status = 'failed' WHERE id = ?").run(log.id);
return res.status(400).json({
error: 'This OTP has been invalidated due to too many failed attempts. Request a new OTP.',
verified: false,
code: 'OTP_INVALIDATED',
});
}
// 4. Check OTP value (hashed or raw debug mode)
let isMatch = false;
if (log.otp_hash.startsWith('$2b$')) {
isMatch = await bcrypt.compare(String(otp), log.otp_hash);
} else {
isMatch = log.otp_hash === String(otp);
}
if (!isMatch) {
const newCount = failedAttempts + 1;
const attemptsRemaining = 5 - newCount;
if (attemptsRemaining <= 0) {
// Lock out on 5th failed attempt
db.prepare("UPDATE otp_logs SET status = 'failed', failed_attempts = ? WHERE id = ?").run(newCount, log.id);
logActivity(userId, 'otp_verification_failed', 'OTP invalidated after too many attempts', `Request: ${request_id}`);
return res.status(400).json({
error: 'Too many failed attempts. This OTP has been invalidated. Request a new OTP.',
verified: false,
code: 'OTP_INVALIDATED',
});
}
db.prepare('UPDATE otp_logs SET failed_attempts = ? WHERE id = ?').run(newCount, log.id);
logActivity(userId, 'otp_verification_failed', 'OTP verification failed', `Request: ${request_id}`);
return res.status(400).json({
error: 'Invalid OTP code provided',
verified: false,
attempts_remaining: attemptsRemaining,
});
}
// 5. Mark as verified
db.prepare("UPDATE otp_logs SET status = 'verified' WHERE id = ?").run(log.id);
logActivity(userId, 'otp_verified', 'OTP verified', `Request: ${request_id}`);
res.json({ success: true, verified: true, message: 'OTP Verified successfully' });
} catch (err) {
console.error('Verify OTP Endpoint Error:', err);
res.status(500).json({ error: 'Internal API Server Error' });
}
});
// ==========================================
// POST /v1/otp/resend (PUBLIC SDK ENDPOINT)
// ==========================================
router.post('/resend', requireApiKey, resendUserLimiter, async (req, res) => {
const startTime = Date.now();
const waReady = req.app.locals.waReady;
const waClient = req.app.locals.waClient;
const { request_id, otp: customOtp } = req.body;
const userId = req.apiKeyUser.id;
const keyMode = req.apiKeyUser.key_mode === 'live' ? 'live' : 'sandbox';
if (!request_id) {
return res.status(400).json({ error: 'request_id of the original OTP is required' });
}
if (customOtp !== undefined) {
const otpStr = String(customOtp);
if (!/^\d{4,16}$/.test(otpStr)) {
return res.status(400).json({ error: 'Custom otp must be 416 digits (numeric only)', code: 'INVALID_CUSTOM_OTP' });
}
}
if (!waReady) {
return res.status(503).json({ error: 'WhatsApp delivery engine is offline. Try again later.' });
}
try {
// 1. Look up original OTP log bound to this user
const original = db.prepare(
'SELECT id, phone, status, expires_at, environment_mode FROM otp_logs WHERE request_id = ? AND user_id = ?'
).get(request_id, userId);
if (!original) {
return res.status(404).json({ error: 'Original OTP request not found or does not belong to your account' });
}
if ((original.environment_mode || 'sandbox') !== keyMode) {
return res.status(403).json({
error: `This request belongs to ${original.environment_mode || 'sandbox'} mode. Use a ${original.environment_mode || 'sandbox'} key.`,
code: 'OTP_MODE_MISMATCH',
});
}
if (original.status === 'verified') {
return res.status(400).json({ error: 'This OTP has already been verified. No need to resend.', code: 'ALREADY_VERIFIED' });
}
if (original.status === 'superseded') {
return res.status(400).json({ error: 'This OTP was already superseded by a previous resend. Use the latest request_id.', code: 'OTP_SUPERSEDED' });
}
// 2. Fetch user settings
const settings = db.prepare(
'SELECT sender_name, greeting, message_template, otp_length, expiry_seconds, webhook_url, return_otp_in_response FROM settings WHERE user_id = ?'
).get(userId);
const user = db.prepare('SELECT phone FROM users WHERE id = ?').get(userId);
const isIntegrationTest = isIntegrationTestRequest(req);
const quotaUnits = keyMode === 'sandbox' && isIntegrationTest
? getSandboxIntegrationQuotaUnits()
: 1;
const quotaMode = resolveQuotaMode({
userId,
environmentMode: keyMode,
quotaUnits,
});
// 3. Sandbox: resend also restricted to registered number
if (keyMode === 'sandbox' && user?.phone && original.phone !== user.phone) {
return res.status(403).json({
error: 'Sandbox mode only allows OTP delivery to your registered WhatsApp number',
code: 'SANDBOX_RECIPIENT_RESTRICTED',
});
}
// 4. Sandbox monthly limit check
if (quotaMode === 'sandbox') {
const usedThisMonth = getSandboxUsageThisMonth(userId);
const monthlyLimit = getUserSandboxMonthlyMessageLimit(userId);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Sandbox monthly message limit reached',
code: 'SANDBOX_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits,
});
}
}
if (quotaMode === 'live') {
const usedThisMonth = getLiveUsageThisMonth(userId);
const monthlyLimit = getUserLiveMonthlyMessageLimit(userId);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Live monthly message limit reached',
code: 'LIVE_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits,
});
}
}
const recipient = await resolveWhatsAppChatId(waClient, original.phone);
if (!recipient.ok) {
return res.status(400).json({ error: recipient.error, code: recipient.code });
}
const chatId = recipient.chatId;
const newRequestId = `req_${crypto.randomBytes(8).toString('hex')}`;
const otpValue = customOtp !== undefined ? String(customOtp) : generateOTP(settings.otp_length);
const expiresAt = new Date(Date.now() + settings.expiry_seconds * 1000).toISOString();
const otpHash = await bcrypt.hash(otpValue, 8);
const textMsg = renderMessageTemplate(settings.message_template || DEFAULT_TEMPLATE, {
greeting: settings.greeting || 'Hello!',
sender_name: settings.sender_name || 'Veriflo',
otp: otpValue,
expiry_seconds: settings.expiry_seconds,
});
// 5. Send new OTP via WhatsApp
try {
await waClient.sendMessage(chatId, textMsg);
} catch (waError) {
console.error('WA Resend Fail:', waError);
return res.status(500).json({ error: 'WhatsApp delivery failed during resend' });
}
const latency = Date.now() - startTime;
// 6. Supersede the old OTP and log the new one
db.prepare("UPDATE otp_logs SET status = 'superseded' WHERE id = ?").run(original.id);
db.prepare(`
INSERT INTO otp_logs (request_id, user_id, api_key_id, phone, otp_hash, status, expires_at, ip_address, user_agent, delivery_latency_ms, resend_of, quota_units, environment_mode, quota_mode)
VALUES (?, ?, ?, ?, ?, 'sent', ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newRequestId, userId, req.apiKeyUser.key_id, original.phone,
settings.return_otp_in_response ? otpValue : otpHash,
expiresAt, req.apiKeyUser.ip, req.apiKeyUser.ua, latency, request_id, quotaUnits, keyMode, quotaMode
);
logActivity(userId, 'otp_resent', 'OTP resent', `Recipient: ${original.phone}`);
await postWebhook(settings.webhook_url, {
event: 'otp.delivered',
request_id: newRequestId,
original_request_id: request_id,
phone: original.phone,
status: 'sent',
environment_mode: keyMode,
provider: 'whatsapp',
expires_at: expiresAt,
delivery_latency_ms: latency,
timestamp: new Date().toISOString(),
});
const payload = {
success: true,
request_id: newRequestId,
expires_at: expiresAt,
message: 'OTP resent successfully via WhatsApp',
quota_units: quotaUnits,
quota_mode: quotaMode,
};
if (settings.return_otp_in_response) payload.debug_otp = otpValue;
res.status(200).json(payload);
} catch (err) {
console.error('Resend OTP Endpoint Error:', err);
res.status(500).json({ error: 'Internal API Server Error' });
}
});
module.exports = router;

671
src/routes/user.js Normal file
View File

@ -0,0 +1,671 @@
const express = require('express');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { db } = require('../db');
const { verifyToken } = require('../middlewares/auth');
const { logActivity } = require('../utils/activity');
const { postWebhook } = require('../utils/webhooks');
const { DEFAULT_TEMPLATE, getTemplateMaxChars, validateMessageTemplate } = require('../utils/messageTemplate');
const {
getSandboxMonthlyMessageLimit,
getLiveMonthlyMessageLimit,
getUserSandboxBonusCredits,
getUserSandboxMonthlyMessageLimit,
getUserLiveBonusCredits,
getUserLiveMonthlyMessageLimit,
getSandboxIntegrationQuotaUnits,
getSandboxUsageThisMonth,
getLiveUsageThisMonth
} = require('../utils/sandbox');
const { resolveWhatsAppChatId } = require('../utils/whatsapp');
const router = express.Router();
const TEST_OTP_USER_PER_MIN = Number(process.env.VERTIFLO_TEST_OTP_USER_PER_MIN || 8);
const TEST_OTP_IP_PER_MIN = Number(process.env.VERTIFLO_TEST_OTP_IP_PER_MIN || 20);
function getRateLimitIpKey(req) {
return ipKeyGenerator(req.ip || req.socket?.remoteAddress || '0.0.0.0');
}
const testOtpIpLimiter = rateLimit({
windowMs: 60 * 1000,
max: TEST_OTP_IP_PER_MIN,
keyGenerator: (req) => getRateLimitIpKey(req),
message: {
error: `Rate limit exceeded: max ${TEST_OTP_IP_PER_MIN} test OTP requests per minute from this IP.`,
code: 'RATE_LIMIT_TEST_OTP_IP'
}
});
const testOtpUserLimiter = rateLimit({
windowMs: 60 * 1000,
max: TEST_OTP_USER_PER_MIN,
keyGenerator: (req) => `user_test_otp_${req.user?.id || getRateLimitIpKey(req)}`,
message: {
error: `Rate limit exceeded: max ${TEST_OTP_USER_PER_MIN} test OTP requests per minute per account.`,
code: 'RATE_LIMIT_TEST_OTP_USER'
}
});
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
const OTP_COST_INR = 0.2;
// Get User Profile & Settings
router.get('/profile', verifyToken, (req, res) => {
try {
const user = db.prepare('SELECT id, name, company, email, phone, role, created_at FROM users WHERE id = ?').get(req.user.id);
const settings = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(req.user.id);
// Get stats
const stats = db.prepare('SELECT count(*) as total_otps FROM otp_logs WHERE user_id = ?').get(req.user.id);
const sandboxUsageThisMonth = getSandboxUsageThisMonth(req.user.id);
const liveUsageThisMonth = getLiveUsageThisMonth(req.user.id);
const sandboxMonthlyLimitBase = getSandboxMonthlyMessageLimit();
const liveMonthlyLimitBase = getLiveMonthlyMessageLimit();
const sandboxBonusCredits = getUserSandboxBonusCredits(req.user.id);
const liveBonusCredits = getUserLiveBonusCredits(req.user.id);
const sandboxMonthlyLimit = getUserSandboxMonthlyMessageLimit(req.user.id);
const liveMonthlyLimit = getUserLiveMonthlyMessageLimit(req.user.id);
const trialStatus = settings?.trial_status || 'not_applied';
const trialActive = trialStatus === 'active';
const trialRemaining = trialActive ? Math.max(0, sandboxMonthlyLimit - sandboxUsageThisMonth) : 0;
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
user,
settings,
plan: {
name: trialActive ? 'Free Trial' : 'Trial Required',
mode: settings?.environment_mode || 'sandbox',
label: trialActive
? `${settings?.environment_mode === 'live' ? 'Live' : 'Sandbox'} / Free Trial`
: 'Free Trial Not Activated',
started_at: user.created_at,
active: trialActive,
trial_status: trialStatus,
trial_applied_at: settings?.trial_applied_at || null,
trial_activated_at: settings?.trial_activated_at || null,
},
limits: {
message_template_max_chars: getTemplateMaxChars(),
sandbox_monthly_message_limit: sandboxMonthlyLimit,
sandbox_monthly_message_limit_base: sandboxMonthlyLimitBase,
sandbox_bonus_credits: sandboxBonusCredits,
live_monthly_message_limit: liveMonthlyLimit,
live_monthly_message_limit_base: liveMonthlyLimitBase,
live_bonus_credits: liveBonusCredits,
test_otp_user_per_min: TEST_OTP_USER_PER_MIN,
test_otp_ip_per_min: TEST_OTP_IP_PER_MIN
},
stats: {
total_otps: stats.total_otps,
sandbox_used_this_month: sandboxUsageThisMonth,
live_used_this_month: liveUsageThisMonth,
sandbox_bonus_credits: sandboxBonusCredits,
live_bonus_credits: liveBonusCredits,
trial_total_credits: sandboxMonthlyLimit,
trial_used_credits: trialActive ? sandboxUsageThisMonth : 0,
trial_remaining_credits: trialRemaining,
credits_remaining: trialRemaining,
live_total_credits: liveMonthlyLimit,
live_remaining_credits: Math.max(0, liveMonthlyLimit - liveUsageThisMonth)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch profile' });
}
});
router.post('/billing/payment-request', verifyToken, (req, res) => {
try {
const credits = Number(req.body?.credits);
const amountInr = Number(req.body?.amount_inr);
const packageName = String(req.body?.package_name || 'Custom Pack').trim();
const paymentConfig = db.prepare(`
SELECT payments_enabled, upi_id, upi_name
FROM payment_config
WHERE id = 1
`).get();
const paymentsEnabled = Number(paymentConfig?.payments_enabled || 0) === 1;
const upiId = String(paymentConfig?.upi_id || process.env.VERTIFLO_UPI_ID || '').trim();
const payeeName = encodeURIComponent(paymentConfig?.upi_name || process.env.VERTIFLO_UPI_NAME || 'Veriflo');
if (!paymentsEnabled) {
return res.status(503).json({ error: 'Payments are currently disabled. Please contact admin.' });
}
if (!upiId) {
return res.status(500).json({ error: 'UPI ID is not configured by admin' });
}
if (!Number.isFinite(credits) || credits <= 0) {
return res.status(400).json({ error: 'credits must be a positive number' });
}
if (!Number.isFinite(amountInr) || amountInr <= 0) {
return res.status(400).json({ error: 'amount_inr must be a positive number' });
}
const requestRef = `pay_${crypto.randomBytes(6).toString('hex')}`;
const note = encodeURIComponent(`Veriflo ${packageName} ${requestRef}`);
const upiLink = `upi://pay?pa=${encodeURIComponent(upiId)}&pn=${payeeName}&am=${amountInr.toFixed(2)}&cu=INR&tn=${note}`;
db.prepare(`
INSERT INTO payment_requests (request_ref, user_id, package_name, credits, amount_inr, upi_link, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
`).run(requestRef, req.user.id, packageName, credits, amountInr, upiLink);
logActivity(req.user.id, 'payment_requested', 'Payment initiated', `${packageName} • ₹${amountInr.toFixed(2)}${credits} credits`);
return res.json({
success: true,
payment: {
request_ref: requestRef,
package_name: packageName,
credits,
amount_inr: amountInr,
upi_link: upiLink,
status: 'pending',
}
});
} catch (err) {
console.error('Create Payment Request Error:', err);
return res.status(500).json({ error: 'Failed to create payment request' });
}
});
router.get('/billing/config', verifyToken, (req, res) => {
try {
const paymentConfig = db.prepare(`
SELECT payments_enabled, upi_id, upi_name, payment_note
FROM payment_config
WHERE id = 1
`).get();
const upiId = String(paymentConfig?.upi_id || process.env.VERTIFLO_UPI_ID || '').trim();
return res.json({
config: {
payments_enabled: Number(paymentConfig?.payments_enabled || 0) === 1,
upi_available: Boolean(upiId),
upi_name: paymentConfig?.upi_name || process.env.VERTIFLO_UPI_NAME || 'Veriflo',
payment_note: paymentConfig?.payment_note || 'Pay via UPI and submit UTR for admin approval.'
}
});
} catch (err) {
console.error('Billing Config Fetch Error:', err);
return res.status(500).json({ error: 'Failed to load billing config' });
}
});
router.post('/billing/payment-request/:requestRef/submit', verifyToken, (req, res) => {
try {
const requestRef = String(req.params.requestRef || '').trim();
const utr = String(req.body?.utr || '').trim();
if (!requestRef) return res.status(400).json({ error: 'requestRef is required' });
const payment = db.prepare(`
SELECT id, status
FROM payment_requests
WHERE request_ref = ? AND user_id = ?
`).get(requestRef, req.user.id);
if (!payment) return res.status(404).json({ error: 'Payment request not found' });
if (!['pending', 'submitted'].includes(payment.status)) {
return res.status(400).json({ error: `Cannot submit this payment in ${payment.status} state` });
}
db.prepare(`
UPDATE payment_requests
SET status = 'submitted',
utr = COALESCE(NULLIF(?, ''), utr),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(utr, payment.id);
logActivity(req.user.id, 'payment_submitted', 'Payment proof submitted', `Ref: ${requestRef}`);
return res.json({ success: true, message: 'Payment submitted for admin review' });
} catch (err) {
console.error('Submit Payment Request Error:', err);
return res.status(500).json({ error: 'Failed to submit payment request' });
}
});
router.get('/billing/payments', verifyToken, (req, res) => {
try {
const payments = db.prepare(`
SELECT request_ref, package_name, credits, amount_inr, utr, status, admin_note, created_at, updated_at, approved_at
FROM payment_requests
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 30
`).all(req.user.id);
return res.json({ payments });
} catch (err) {
console.error('List Billing Payments Error:', err);
return res.status(500).json({ error: 'Failed to load payment history' });
}
});
router.put('/profile', verifyToken, async (req, res) => {
const { name, company, new_password } = req.body;
if (!name || !String(name).trim()) {
return res.status(400).json({ error: 'Name is required' });
}
try {
db.prepare('UPDATE users SET name = ?, company = ? WHERE id = ?')
.run(String(name).trim(), String(company || '').trim(), req.user.id);
if (new_password && String(new_password).trim()) {
const passwordHash = await require('bcrypt').hash(String(new_password), 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, req.user.id);
}
const user = db.prepare('SELECT id, name, company, email, phone, role, created_at FROM users WHERE id = ?').get(req.user.id);
logActivity(req.user.id, 'profile_updated', 'Profile updated', 'Account details changed from dashboard settings');
res.json({ message: 'Profile updated successfully', user });
} catch (err) {
console.error('Profile Update Error:', err);
res.status(500).json({ error: 'Failed to update profile' });
}
});
router.get('/api-keys', verifyToken, (req, res) => {
try {
const keys = db.prepare(`
SELECT
ak.id,
ak.name,
ak.prefix,
ak.mode,
ak.last_used_ip,
ak.last_used_at,
ak.created_at,
COALESCE(day_stats.total, 0) as usage_today,
COALESCE(week_stats.total, 0) as usage_week,
COALESCE(month_stats.total, 0) as usage_month
FROM api_keys ak
LEFT JOIN (
SELECT api_key_id, COUNT(*) as total
FROM otp_logs
WHERE created_at >= datetime('now', '-1 day')
GROUP BY api_key_id
) day_stats ON day_stats.api_key_id = ak.id
LEFT JOIN (
SELECT api_key_id, COUNT(*) as total
FROM otp_logs
WHERE created_at >= datetime('now', '-7 days')
GROUP BY api_key_id
) week_stats ON week_stats.api_key_id = ak.id
LEFT JOIN (
SELECT api_key_id, COUNT(*) as total
FROM otp_logs
WHERE created_at >= datetime('now', '-30 days')
GROUP BY api_key_id
) month_stats ON month_stats.api_key_id = ak.id
WHERE ak.user_id = ?
ORDER BY ak.created_at DESC
`).all(req.user.id);
res.json({
keys: keys.map((key) => ({
...key,
cost_today: Number((key.usage_today * OTP_COST_INR).toFixed(2)),
cost_week: Number((key.usage_week * OTP_COST_INR).toFixed(2)),
cost_month: Number((key.usage_month * OTP_COST_INR).toFixed(2)),
})),
limit_per_mode: 2,
limit_total: 4
});
} catch (err) {
console.error('List API Keys Error:', err);
res.status(500).json({ error: 'Failed to load API keys' });
}
});
router.post('/trial/apply', verifyToken, (req, res) => {
try {
const settings = db.prepare('SELECT trial_status FROM settings WHERE user_id = ?').get(req.user.id);
if (!settings) {
return res.status(404).json({ error: 'Settings not found for this user' });
}
if (settings.trial_status === 'active') {
return res.json({ success: true, trial_status: 'active', message: 'Free trial is already active' });
}
db.prepare(`
UPDATE settings
SET trial_status = 'applied',
trial_applied_at = COALESCE(trial_applied_at, CURRENT_TIMESTAMP)
WHERE user_id = ?
`).run(req.user.id);
logActivity(req.user.id, 'trial_applied', 'Free trial applied', 'User applied for free trial access');
return res.json({ success: true, trial_status: 'applied', message: 'Free trial application submitted' });
} catch (err) {
console.error('Trial Apply Error:', err);
return res.status(500).json({ error: 'Failed to apply for free trial' });
}
});
router.post('/trial/activate', verifyToken, (req, res) => {
try {
const settings = db.prepare('SELECT trial_status FROM settings WHERE user_id = ?').get(req.user.id);
if (!settings) {
return res.status(404).json({ error: 'Settings not found for this user' });
}
if (settings.trial_status === 'active') {
return res.json({ success: true, trial_status: 'active', message: 'Free trial is already active' });
}
if (settings.trial_status !== 'applied') {
return res.status(400).json({
error: 'Apply for free trial first before activation',
code: 'TRIAL_NOT_APPLIED'
});
}
db.prepare(`
UPDATE settings
SET trial_status = 'active',
trial_activated_at = COALESCE(trial_activated_at, CURRENT_TIMESTAMP)
WHERE user_id = ?
`).run(req.user.id);
logActivity(req.user.id, 'trial_activated', 'Free trial activated', 'User activated free trial');
return res.json({ success: true, trial_status: 'active', message: 'Free trial activated successfully' });
} catch (err) {
console.error('Trial Activate Error:', err);
return res.status(500).json({ error: 'Failed to activate free trial' });
}
});
// Generate new API Key
router.post('/api-key', verifyToken, (req, res) => {
try {
const settings = db.prepare('SELECT trial_status FROM settings WHERE user_id = ?').get(req.user.id);
if ((settings?.trial_status || 'not_applied') !== 'active') {
return res.status(403).json({
error: 'Activate free trial to create API keys',
code: 'TRIAL_NOT_ACTIVE'
});
}
const keyName = String(req.body.name || '').trim();
const keyMode = req.body.mode === 'live' ? 'live' : 'sandbox';
if (!keyName) {
return res.status(400).json({ error: 'Key name is required' });
}
const currentModeCount = db.prepare('SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND mode = ?').get(req.user.id, keyMode).count;
if (currentModeCount >= 2) {
return res.status(400).json({ error: `You can only have 2 active ${keyMode} API keys at a time.` });
}
const totalCount = db.prepare('SELECT COUNT(*) as count FROM api_keys WHERE user_id = ?').get(req.user.id).count;
if (totalCount >= 4) {
return res.status(400).json({ error: 'You can only have 4 active API keys in total (2 sandbox + 2 live).' });
}
// 1. Generate a secure random string (32 bytes = 64 hex chars)
const rawSecret = crypto.randomBytes(32).toString('hex');
const prefix = keyMode === 'live' ? 'vt_live_' : 'vt_sandbox_';
// The actual key the user sees ONCE
const plainApiKey = `${prefix}${rawSecret}`;
// 2. Hash it for DB storage (SHA-256 is fast and sufficient for API keys)
const keyHash = crypto.createHash('sha256').update(plainApiKey).digest('hex');
// 3. Save to DB
const result = db.prepare('INSERT INTO api_keys (user_id, name, key_hash, prefix, mode) VALUES (?, ?, ?, ?, ?)')
.run(req.user.id, keyName, keyHash, prefix, keyMode);
logActivity(req.user.id, 'api_key_generated', 'API key generated', `Key: ${keyName} (${keyMode})`);
res.json({
message: 'New API Key generated successfully.',
key: {
id: result.lastInsertRowid,
name: keyName,
prefix,
mode: keyMode,
created_at: new Date().toISOString(),
},
apiKey: plainApiKey,
warning: 'Please copy this key now. It will never be shown again.'
});
} catch (err) {
console.error('API Key Generation Error:', err);
res.status(500).json({ error: 'Failed to generate API Key' });
}
});
router.delete('/api-key/:id', verifyToken, (req, res) => {
try {
const key = db.prepare('SELECT id, name FROM api_keys WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!key) {
return res.status(404).json({ error: 'API key not found' });
}
db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
logActivity(req.user.id, 'api_key_deleted', 'API key deleted', `Key: ${key.name}`);
res.json({ message: 'API key deleted successfully' });
} catch (err) {
console.error('Delete API Key Error:', err);
res.status(500).json({ error: 'Failed to delete API key' });
}
});
// Update Message Configuration Settings
router.put('/settings', verifyToken, (req, res) => {
const { sender_name, greeting, message_template, otp_length, expiry_seconds, environment_mode, webhook_url, return_otp_in_response } = req.body;
if (environment_mode && !['sandbox', 'live'].includes(environment_mode)) {
return res.status(400).json({ error: 'environment_mode must be either sandbox or live' });
}
if (message_template !== undefined) {
const validation = validateMessageTemplate(message_template);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
}
try {
const stmt = db.prepare(`
UPDATE settings
SET sender_name = COALESCE(?, sender_name),
greeting = COALESCE(?, greeting),
message_template = COALESCE(?, message_template),
otp_length = COALESCE(?, otp_length),
expiry_seconds = COALESCE(?, expiry_seconds),
environment_mode = COALESCE(?, environment_mode),
webhook_url = COALESCE(?, webhook_url),
return_otp_in_response = COALESCE(?, return_otp_in_response)
WHERE user_id = ?
`);
stmt.run(
sender_name,
greeting,
message_template,
otp_length,
expiry_seconds,
environment_mode,
webhook_url,
return_otp_in_response !== undefined ? (return_otp_in_response ? 1 : 0) : null,
req.user.id
);
const updatedSettings = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(req.user.id);
if (!updatedSettings.message_template) {
db.prepare('UPDATE settings SET message_template = ? WHERE user_id = ?').run(DEFAULT_TEMPLATE, req.user.id);
updatedSettings.message_template = DEFAULT_TEMPLATE;
}
logActivity(req.user.id, 'settings_updated', 'Message settings updated', `Mode: ${updatedSettings.environment_mode || 'sandbox'}`);
res.json({ message: 'Settings updated', settings: updatedSettings });
} catch (err) {
console.error('Settings Update Error:', err);
res.status(500).json({ error: 'Failed to update settings' });
}
});
// Send Test OTP (Internal Dashboard Utility)
router.post('/test-otp', verifyToken, testOtpIpLimiter, testOtpUserLimiter, async (req, res) => {
const { phone } = req.body;
if (!phone) return res.status(400).json({ error: 'Phone number is required' });
const waReady = req.app.locals.waReady;
const waClient = req.app.locals.waClient;
if (!waReady) {
return res.status(503).json({ error: 'WhatsApp engine is offline' });
}
try {
const sanitizedPhone = String(phone).replace(/\D/g, "");
const user = db.prepare('SELECT phone FROM users WHERE id = ?').get(req.user.id);
const settings = db.prepare('SELECT environment_mode FROM settings WHERE user_id = ?').get(req.user.id);
const environmentMode = settings?.environment_mode === 'live' ? 'live' : 'sandbox';
if (environmentMode === 'sandbox' && user?.phone && sanitizedPhone !== user.phone) {
return res.status(403).json({
error: 'Sandbox mode only allows OTP delivery to your registered WhatsApp number',
code: 'SANDBOX_RECIPIENT_RESTRICTED',
allowed_phone: user.phone
});
}
const quotaUnits = environmentMode === 'sandbox' ? getSandboxIntegrationQuotaUnits() : 1;
let quotaMode = environmentMode;
if (environmentMode === 'sandbox') {
const liveBonusCredits = getUserLiveBonusCredits(req.user.id);
if (liveBonusCredits > 0) {
const liveUsed = getLiveUsageThisMonth(req.user.id);
const liveLimit = getUserLiveMonthlyMessageLimit(req.user.id);
if (liveUsed + quotaUnits <= liveLimit) {
quotaMode = 'live';
}
}
}
if (quotaMode === 'sandbox') {
const usedThisMonth = getSandboxUsageThisMonth(req.user.id);
const monthlyLimit = getUserSandboxMonthlyMessageLimit(req.user.id);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Sandbox mode monthly message limit reached',
code: 'SANDBOX_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits
});
}
}
if (quotaMode === 'live') {
const usedThisMonth = getLiveUsageThisMonth(req.user.id);
const monthlyLimit = getUserLiveMonthlyMessageLimit(req.user.id);
if (usedThisMonth + quotaUnits > monthlyLimit) {
return res.status(403).json({
error: 'Live mode monthly message limit reached',
code: 'LIVE_MONTHLY_LIMIT_REACHED',
monthly_limit: monthlyLimit,
used_this_month: usedThisMonth,
attempted_quota_units: quotaUnits
});
}
}
const recipient = await resolveWhatsAppChatId(waClient, sanitizedPhone);
if (!recipient.ok) {
return res.status(400).json({ error: recipient.error, code: recipient.code });
}
const chatId = recipient.chatId;
const testOtp = Math.floor(100000 + Math.random() * 900000);
const requestId = `test_${crypto.randomBytes(8).toString('hex')}`;
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const message = `🚀 *Veriflo Test Message*\n\nYour test verification code is: *${testOtp}*\n\nIf you're seeing this, your dashboard integration is working perfectly!`;
await waClient.sendMessage(chatId, message);
db.prepare(`
INSERT INTO otp_logs (request_id, user_id, api_key_id, phone, otp_hash, status, expires_at, ip_address, user_agent, delivery_latency_ms, quota_units, environment_mode, quota_mode)
VALUES (?, ?, NULL, ?, ?, 'sent', ?, ?, ?, NULL, ?, ?, ?)
`).run(
requestId,
req.user.id,
sanitizedPhone,
String(testOtp),
expiresAt,
req.ip || null,
req.get('user-agent') || null,
quotaUnits,
environmentMode,
quotaMode
);
logActivity(req.user.id, 'test_otp_sent', 'Test OTP sent', `Recipient: ${sanitizedPhone}`);
res.json({ success: true, message: 'Test message sent to ' + phone, quota_units: quotaUnits, quota_mode: quotaMode });
} catch (err) {
console.error('Test OTP Error:', err);
res.status(500).json({ error: 'Failed to send test message' });
}
});
router.post('/test-webhook', verifyToken, async (req, res) => {
try {
const settings = db.prepare('SELECT webhook_url, environment_mode FROM settings WHERE user_id = ?').get(req.user.id);
const user = db.prepare('SELECT email, phone FROM users WHERE id = ?').get(req.user.id);
if (!settings?.webhook_url) {
return res.status(400).json({ error: 'No webhook URL configured' });
}
const payload = {
event: 'otp.test_delivery_summary',
status: 'sent',
environment_mode: settings.environment_mode || 'sandbox',
timestamp: new Date().toISOString(),
summary: {
total_sent_today: db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND created_at >= datetime('now', '-1 day')").get(req.user.id).count,
total_verified_today: db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status = 'verified' AND created_at >= datetime('now', '-1 day')").get(req.user.id).count,
total_failed_today: db.prepare("SELECT COUNT(*) as count FROM otp_logs WHERE user_id = ? AND status IN ('failed', 'failed_attempt') AND created_at >= datetime('now', '-1 day')").get(req.user.id).count,
},
sample_request: {
request_id: `test_${crypto.randomBytes(6).toString('hex')}`,
phone: user?.phone || null,
email: user?.email || null,
}
};
const result = await postWebhook(settings.webhook_url, payload);
logActivity(req.user.id, 'webhook_test_sent', 'Webhook test sent', `Status: ${result.status || result.error || 'unknown'}`);
if (result.ok) {
return res.json({ success: true, message: 'Test webhook delivered successfully', status: result.status });
}
return res.status(502).json({
error: 'Webhook endpoint did not accept the test payload',
status: result.status,
details: result.error || null
});
} catch (err) {
console.error('Test Webhook Error:', err);
res.status(500).json({ error: 'Failed to send test webhook' });
}
});
module.exports = router;

18
src/utils/activity.js Normal file
View File

@ -0,0 +1,18 @@
const { db } = require('../db');
function logActivity(userId, type, title, meta = '') {
if (!userId || !type || !title) return;
try {
db.prepare(`
INSERT INTO activity_logs (user_id, type, title, meta)
VALUES (?, ?, ?, ?)
`).run(userId, type, title, meta);
} catch (err) {
console.error('Activity Log Error:', err);
}
}
module.exports = {
logActivity
};

31
src/utils/env.js Normal file
View File

@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
function loadEnvFile() {
const envPath = path.join(__dirname, '..', '..', '.env');
if (!fs.existsSync(envPath)) return;
const content = fs.readFileSync(envPath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const index = line.indexOf('=');
if (index === -1) continue;
const key = line.slice(0, index).trim();
let value = line.slice(index + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
module.exports = {
loadEnvFile
};

148
src/utils/logger.js Normal file
View File

@ -0,0 +1,148 @@
const fs = require('fs');
const path = require('path');
const ANSI = {
reset: '\x1b[0m',
gray: '\x1b[90m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
};
// Ensure logs directory exists
const logsDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Log file path with date-based naming
const getLogFilePath = () => {
const today = new Date().toISOString().split('T')[0];
return path.join(logsDir, `requests-${today}.log`);
};
/**
* Express middleware to log all incoming requests
* Logs: timestamp, method, path, query, IP, status code, response time
*/
const requestLogger = (req, res, next) => {
const startTime = Date.now();
// Capture the original res.end function
const originalEnd = res.end;
res.end = function(chunk, encoding) {
const duration = Date.now() - startTime;
const logEntry = formatLogEntry(req, res, duration);
appendLog(logEntry);
printConsoleLog(req, res, duration);
// Call original res.end
originalEnd.call(this, chunk, encoding);
};
next();
};
function getStatusColor(statusCode) {
if (statusCode >= 500) return ANSI.red;
if (statusCode >= 400) return ANSI.yellow;
if (statusCode >= 300) return ANSI.cyan;
return ANSI.green;
}
function getMethodColor(method) {
if (method === 'POST') return ANSI.cyan;
if (method === 'PUT' || method === 'PATCH') return ANSI.yellow;
if (method === 'DELETE') return ANSI.red;
return ANSI.green;
}
function printConsoleLog(req, res, duration) {
const timestamp = new Date().toISOString();
const method = req.method;
const statusCode = res.statusCode;
const methodColor = getMethodColor(method);
const statusColor = getStatusColor(statusCode);
const pathValue = req.originalUrl || req.path;
const line = [
`${ANSI.gray}[${timestamp}]${ANSI.reset}`,
`${methodColor}${method}${ANSI.reset}`,
`${pathValue}`,
`${statusColor}${statusCode}${ANSI.reset}`,
`${ANSI.gray}${duration}ms${ANSI.reset}`,
].join(' ');
console.log(line);
}
/**
* Format log entry with timestamp and request details
*/
const formatLogEntry = (req, res, duration) => {
const timestamp = new Date().toISOString();
const ip = req.ip || req.connection.remoteAddress || 'UNKNOWN';
const method = req.method;
const path = req.path;
const query = Object.keys(req.query).length > 0 ? JSON.stringify(req.query) : '-';
const statusCode = res.statusCode;
return `[${timestamp}] ${method} ${path} ${query} | IP: ${ip} | Status: ${statusCode} | Duration: ${duration}ms`;
};
/**
* Append log entry to file
*/
const appendLog = (logEntry) => {
const logFile = getLogFilePath();
try {
fs.appendFileSync(logFile, logEntry + '\n');
} catch (error) {
console.error('Failed to write to log file:', error.message);
}
};
/**
* Utility to get recent logs (optional, for viewing logs via API)
*/
const getRecentLogs = (lines = 50) => {
const logFile = getLogFilePath();
try {
if (!fs.existsSync(logFile)) {
return [];
}
const content = fs.readFileSync(logFile, 'utf-8');
return content.split('\n').filter(line => line.trim()).slice(-lines);
} catch (error) {
console.error('Failed to read log file:', error.message);
return [];
}
};
/**
* Clear logs (dangerous, use with caution)
*/
const clearLogs = () => {
const logFile = getLogFilePath();
try {
if (fs.existsSync(logFile)) {
fs.unlinkSync(logFile);
return true;
}
} catch (error) {
console.error('Failed to clear log file:', error.message);
}
return false;
};
module.exports = {
requestLogger,
getRecentLogs,
clearLogs
};

View File

@ -0,0 +1,58 @@
const DEFAULT_TEMPLATE = `{greeting} Your {sender_name} verification code is:
*{otp}*
This code expires in {expiry_seconds} seconds. Do not share it.`;
const ALLOWED_VARIABLES = ['greeting', 'sender_name', 'otp', 'expiry_seconds'];
function getTemplateMaxChars() {
const value = Number(process.env.VERTIFLO_MESSAGE_TEMPLATE_MAX_CHARS || 320);
return Number.isFinite(value) && value > 0 ? value : 320;
}
function renderMessageTemplate(template, values) {
let output = template || DEFAULT_TEMPLATE;
for (const variable of ALLOWED_VARIABLES) {
const pattern = new RegExp(`\\{${variable}\\}`, 'g');
output = output.replace(pattern, String(values[variable] ?? ''));
}
return output;
}
function validateMessageTemplate(template) {
const maxChars = getTemplateMaxChars();
const input = String(template || '').trim();
if (!input) {
return { valid: false, error: 'Message template cannot be empty' };
}
if (input.length > maxChars) {
return { valid: false, error: `Message template cannot exceed ${maxChars} characters` };
}
const matches = input.match(/\{([^}]+)\}/g) || [];
for (const match of matches) {
const variable = match.slice(1, -1);
if (!ALLOWED_VARIABLES.includes(variable)) {
return { valid: false, error: `Unsupported variable ${match}` };
}
}
if (!input.includes('{otp}')) {
return { valid: false, error: 'Message template must include {otp}' };
}
return { valid: true };
}
module.exports = {
ALLOWED_VARIABLES,
DEFAULT_TEMPLATE,
getTemplateMaxChars,
renderMessageTemplate,
validateMessageTemplate
};

76
src/utils/sandbox.js Normal file
View File

@ -0,0 +1,76 @@
const { db } = require('../db');
function getSandboxMonthlyMessageLimit() {
const value = Number(process.env.VERTIFLO_SANDBOX_MONTHLY_MESSAGE_LIMIT || 500);
return Number.isFinite(value) && value > 0 ? value : 500;
}
function getLiveMonthlyMessageLimit() {
const value = Number(process.env.VERTIFLO_LIVE_MONTHLY_MESSAGE_LIMIT || 100);
return Number.isFinite(value) && value > 0 ? value : 100;
}
function getUserSandboxBonusCredits(userId) {
const row = db.prepare('SELECT trial_bonus_credits FROM settings WHERE user_id = ?').get(userId);
const bonus = Number(row?.trial_bonus_credits || 0);
return Number.isFinite(bonus) && bonus > 0 ? Number(bonus.toFixed(2)) : 0;
}
function getUserSandboxMonthlyMessageLimit(userId) {
const base = getSandboxMonthlyMessageLimit();
const bonus = getUserSandboxBonusCredits(userId);
return Number((base + bonus).toFixed(2));
}
function getUserLiveBonusCredits(userId) {
const row = db.prepare('SELECT live_bonus_credits FROM settings WHERE user_id = ?').get(userId);
const bonus = Number(row?.live_bonus_credits || 0);
return Number.isFinite(bonus) && bonus > 0 ? Number(bonus.toFixed(2)) : 0;
}
function getUserLiveMonthlyMessageLimit(userId) {
const base = getLiveMonthlyMessageLimit();
const bonus = getUserLiveBonusCredits(userId);
return Number((base + bonus).toFixed(2));
}
function getSandboxIntegrationQuotaUnits() {
// Sandbox test OTP now always consumes 1 credit.
return 1;
}
function getSandboxUsageThisMonth(userId) {
const result = db.prepare(`
SELECT COALESCE(SUM(COALESCE(quota_units, 1)), 0) as used
FROM otp_logs
WHERE user_id = ?
AND COALESCE(quota_mode, environment_mode, 'sandbox') = 'sandbox'
AND created_at >= datetime('now', 'start of month')
`).get(userId);
return Number((result?.used || 0).toFixed(2));
}
function getLiveUsageThisMonth(userId) {
const result = db.prepare(`
SELECT COALESCE(SUM(COALESCE(quota_units, 1)), 0) as used
FROM otp_logs
WHERE user_id = ?
AND COALESCE(quota_mode, environment_mode, 'sandbox') = 'live'
AND created_at >= datetime('now', 'start of month')
`).get(userId);
return Number((result?.used || 0).toFixed(2));
}
module.exports = {
getSandboxMonthlyMessageLimit,
getLiveMonthlyMessageLimit,
getUserSandboxBonusCredits,
getUserSandboxMonthlyMessageLimit,
getUserLiveBonusCredits,
getUserLiveMonthlyMessageLimit,
getSandboxIntegrationQuotaUnits,
getSandboxUsageThisMonth,
getLiveUsageThisMonth
};

28
src/utils/webhooks.js Normal file
View File

@ -0,0 +1,28 @@
async function postWebhook(url, payload) {
if (!url) return { skipped: true };
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
return {
ok: response.ok,
status: response.status,
};
} catch (err) {
console.error('Webhook POST Error:', err);
return {
ok: false,
error: err.message || 'Webhook request failed',
};
}
}
module.exports = {
postWebhook
};

52
src/utils/whatsapp.js Normal file
View File

@ -0,0 +1,52 @@
function extractChatId(numberId) {
if (!numberId) return null;
if (typeof numberId === 'string') return numberId;
if (typeof numberId._serialized === 'string') return numberId._serialized;
if (numberId.id && typeof numberId.id._serialized === 'string') return numberId.id._serialized;
return null;
}
async function resolveWhatsAppChatId(waClient, phone) {
const normalizedPhone = String(phone || '').replace(/\D/g, '');
if (normalizedPhone.length < 7 || normalizedPhone.length > 15) {
return { ok: false, code: 'INVALID_PHONE', error: 'Invalid phone number format' };
}
if (!waClient || typeof waClient.getNumberId !== 'function') {
return { ok: true, chatId: `${normalizedPhone}@c.us` };
}
try {
const numberId = await waClient.getNumberId(normalizedPhone);
const chatId = extractChatId(numberId);
if (!chatId) {
return {
ok: false,
code: 'WHATSAPP_RECIPIENT_NOT_FOUND',
error: 'This number is not reachable on WhatsApp',
};
}
return { ok: true, chatId };
} catch (error) {
const message = String(error?.message || '');
if (message.includes('No LID for user')) {
return {
ok: false,
code: 'WHATSAPP_RECIPIENT_NOT_FOUND',
error: 'This number is not reachable on WhatsApp',
};
}
return {
ok: false,
code: 'WHATSAPP_LOOKUP_FAILED',
error: 'Could not validate WhatsApp recipient at this time',
};
}
}
module.exports = {
resolveWhatsAppChatId,
};