feat: add Plivo SMS admin panel with rate limits
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
requireAdmin,
|
||||
} from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { getSmsLogs, sendSms, sendSmsToUsers } from "../services/plivo";
|
||||
import { backfillUserHistory } from "../services/tautulli";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
@@ -91,6 +92,8 @@ router.get(
|
||||
totalEarned: true,
|
||||
totalSpent: true,
|
||||
watchTimeMinutes: true,
|
||||
phoneNumber: true,
|
||||
smsOptOut: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
@@ -331,4 +334,74 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/users/:id/phone",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { phoneNumber, smsOptOut } = req.body;
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
phoneNumber: phoneNumber || null,
|
||||
smsOptOut: smsOptOut ?? false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
phoneNumber: true,
|
||||
smsOptOut: true,
|
||||
},
|
||||
});
|
||||
res.json(user);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/sms/send",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { userIds, phoneNumbers, message } = req.body;
|
||||
if (!message || message.trim().length === 0) {
|
||||
return res.status(400).json({ error: "Message required" });
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
// Send to users by ID
|
||||
if (Array.isArray(userIds) && userIds.length > 0) {
|
||||
const userResults = await sendSmsToUsers(userIds, message);
|
||||
results.push(...userResults);
|
||||
}
|
||||
|
||||
// Send to raw phone numbers
|
||||
if (Array.isArray(phoneNumbers) && phoneNumbers.length > 0) {
|
||||
for (const num of phoneNumbers) {
|
||||
const result = await sendSms(null, num, message);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const sent = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
res.json({ success: true, sent, failed, results });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/sms/logs",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { limit = "50", offset = "0" } = req.query;
|
||||
const logs = await getSmsLogs(
|
||||
parseInt(limit as string),
|
||||
parseInt(offset as string),
|
||||
);
|
||||
res.json({ logs });
|
||||
}),
|
||||
);
|
||||
|
||||
export { router as adminRouter };
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import axios from "axios";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const PLIVO_AUTH_ID = process.env.PLIVO_AUTH_ID || "";
|
||||
const PLIVO_AUTH_TOKEN = process.env.PLIVO_AUTH_TOKEN || "";
|
||||
const PLIVO_SRC_NUMBER = process.env.PLIVO_SRC_NUMBER || "";
|
||||
|
||||
const plivoClient = axios.create({
|
||||
baseURL: `https://api.plivo.com/v1/Account/${PLIVO_AUTH_ID}`,
|
||||
auth: {
|
||||
username: PLIVO_AUTH_ID,
|
||||
password: PLIVO_AUTH_TOKEN,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
// In-memory rate limiter for free trial: 1 SMS per 60s globally
|
||||
const lastSentGlobal = new Map<string, number>(); // key: "global", value: timestamp
|
||||
const lastSentPerUser = new Map<string, number>(); // key: userId, value: timestamp
|
||||
|
||||
const GLOBAL_COOLDOWN_MS = 60_000; // 60 seconds between ANY sends
|
||||
const PER_USER_COOLDOWN_MS = 300_000; // 5 minutes per user
|
||||
|
||||
interface SendSmsResult {
|
||||
success: boolean;
|
||||
phoneNumber: string;
|
||||
userId?: string;
|
||||
plivoUuid?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
userId: string | null,
|
||||
phoneNumber: string,
|
||||
message: string,
|
||||
): Promise<SendSmsResult> {
|
||||
const now = Date.now();
|
||||
|
||||
// Global rate limit
|
||||
const lastGlobal = lastSentGlobal.get("global") || 0;
|
||||
if (now - lastGlobal < GLOBAL_COOLDOWN_MS) {
|
||||
const waitSec = Math.ceil((GLOBAL_COOLDOWN_MS - (now - lastGlobal)) / 1000);
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId: userId || undefined,
|
||||
error: `Free trial cooldown: wait ${waitSec}s before next message`,
|
||||
};
|
||||
}
|
||||
|
||||
// Per-user rate limit
|
||||
if (userId) {
|
||||
const lastUser = lastSentPerUser.get(userId) || 0;
|
||||
if (now - lastUser < PER_USER_COOLDOWN_MS) {
|
||||
const waitSec = Math.ceil(
|
||||
(PER_USER_COOLDOWN_MS - (now - lastUser)) / 1000,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId,
|
||||
error: `Per-user cooldown: wait ${waitSec}s before messaging this chicken again`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Plivo config
|
||||
if (!PLIVO_AUTH_ID || !PLIVO_AUTH_TOKEN) {
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId: userId || undefined,
|
||||
error: "Plivo credentials not configured",
|
||||
};
|
||||
}
|
||||
if (!PLIVO_SRC_NUMBER) {
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId: userId || undefined,
|
||||
error:
|
||||
"PLIVO_SRC_NUMBER not configured. Set your Plivo phone number in .env",
|
||||
};
|
||||
}
|
||||
|
||||
// Validate phone number (E.164 format)
|
||||
let to = phoneNumber.trim();
|
||||
if (!to.startsWith("+")) {
|
||||
to = `+1${to.replace(/\D/g, "")}`; // Assume US if no + prefix
|
||||
}
|
||||
if (!/^\+\d{10,15}$/.test(to)) {
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId: userId || undefined,
|
||||
error: `Invalid phone number: ${to}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate message
|
||||
const cleanMessage = message.trim();
|
||||
if (!cleanMessage || cleanMessage.length > 1600) {
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber,
|
||||
userId: userId || undefined,
|
||||
error: `Message must be 1-1600 characters`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
src: PLIVO_SRC_NUMBER,
|
||||
dst: to,
|
||||
text: cleanMessage,
|
||||
};
|
||||
|
||||
const response = await plivoClient.post("/Message/", payload);
|
||||
const uuid =
|
||||
response.data?.message_uuid?.[0] || response.data?.message_uuid;
|
||||
|
||||
// Update rate limiters
|
||||
lastSentGlobal.set("global", Date.now());
|
||||
if (userId) lastSentPerUser.set(userId, Date.now());
|
||||
|
||||
// Log to database
|
||||
await prisma.smsLog.create({
|
||||
data: {
|
||||
userId,
|
||||
phoneNumber: to,
|
||||
message: cleanMessage,
|
||||
status: "SENT",
|
||||
plivoUuid: uuid || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
phoneNumber: to,
|
||||
userId: userId || undefined,
|
||||
plivoUuid: uuid,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const errorMsg =
|
||||
err.response?.data?.error || err.message || "Plivo API error";
|
||||
|
||||
await prisma.smsLog.create({
|
||||
data: {
|
||||
userId,
|
||||
phoneNumber: to,
|
||||
message: cleanMessage,
|
||||
status: "FAILED",
|
||||
error: errorMsg,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
phoneNumber: to,
|
||||
userId: userId || undefined,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSmsToUsers(
|
||||
userIds: string[],
|
||||
message: string,
|
||||
): Promise<SendSmsResult[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
phoneNumber: { not: null },
|
||||
smsOptOut: false,
|
||||
},
|
||||
select: { id: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
const results: SendSmsResult[] = [];
|
||||
for (const user of users) {
|
||||
if (!user.phoneNumber) continue;
|
||||
const result = await sendSms(user.id, user.phoneNumber, message);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getSmsLogs(limit = 50, offset = 0) {
|
||||
return prisma.smsLog.findMany({
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
user: {
|
||||
select: { plexUsername: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user