Initial GIT Veriflo - Backend
This commit is contained in:
commit
bb43aed936
12
.env.example
Normal file
12
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.wwebjs_auth/
|
||||
.wwebjs_cache/
|
||||
.env
|
||||
BIN
database.sqlite
Normal file
BIN
database.sqlite
Normal file
Binary file not shown.
BIN
database.sqlite-shm
Normal file
BIN
database.sqlite-shm
Normal file
Binary file not shown.
BIN
database.sqlite-wal
Normal file
BIN
database.sqlite-wal
Normal file
Binary file not shown.
144
index.js
Normal file
144
index.js
Normal 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}`);
|
||||
});
|
||||
42
logs/requests-2026-03-12.log
Normal file
42
logs/requests-2026-03-12.log
Normal 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
|
||||
3
logs/requests-2026-03-13.log
Normal file
3
logs/requests-2026-03-13.log
Normal 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
|
||||
4
logs/requests-2026-03-15.log
Normal file
4
logs/requests-2026-03-15.log
Normal 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
|
||||
663
logs/requests-2026-03-19.log
Normal file
663
logs/requests-2026-03-19.log
Normal 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
3294
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
249
src/db.js
Normal 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
68
src/middlewares/apiKey.js
Normal 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
35
src/middlewares/auth.js
Normal 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
400
src/routes/admin.js
Normal 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
290
src/routes/analytics.js
Normal 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
375
src/routes/auth.js
Normal 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
570
src/routes/otp.js
Normal 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 4–16 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 4–16 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
671
src/routes/user.js
Normal 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
18
src/utils/activity.js
Normal 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
31
src/utils/env.js
Normal 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
148
src/utils/logger.js
Normal 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
|
||||
};
|
||||
58
src/utils/messageTemplate.js
Normal file
58
src/utils/messageTemplate.js
Normal 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
76
src/utils/sandbox.js
Normal 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
28
src/utils/webhooks.js
Normal 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
52
src/utils/whatsapp.js
Normal 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,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user