feat: add Plivo SMS admin panel with rate limits

This commit is contained in:
2026-04-23 15:02:39 -04:00
parent 3029ce7053
commit d72cc63c7a
7 changed files with 549 additions and 23 deletions
+73
View File
@@ -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 };
+199
View File
@@ -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 },
},
},
});
}