diff --git a/.env.example b/.env.example index d78534b..7bc8fd4 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,15 @@ OVERSEER_API_KEY="" # ========================================== KOFI_VERIFICATION_TOKEN="" +# ========================================== +# Plivo (SMS Notifications) +# Get credentials from Plivo Console > Account Settings +# Free trial: 1 SMS per 60 seconds enforced in code +# ========================================== +PLIVO_AUTH_ID="" +PLIVO_AUTH_TOKEN="" +PLIVO_SRC_NUMBER="" # Your Plivo phone number (e.g. +12125551234) + # ========================================== # Redis (optional caching) # ========================================== diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 45c1f96..c3ea6ef 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -18,12 +18,15 @@ model User { totalEarned Int @default(0) @map("total_earned") totalSpent Int @default(0) @map("total_spent") watchTimeMinutes Int @default(0) @map("watch_time_minutes") + phoneNumber String? @map("phone_number") + smsOptOut Boolean @default(false) @map("sms_opt_out") transactions Transaction[] watchEvents WatchEvent[] contentRequests ContentRequest[] sessions Session[] kofiPayments KofiPayment[] + smsLogs SmsLog[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -140,6 +143,22 @@ model KofiPayment { @@map("kofi_payments") } +model SmsLog { + id String @id @default(uuid()) + userId String? @map("user_id") + user User? @relation(fields: [userId], references: [id]) + phoneNumber String @map("phone_number") + message String + status String @default("SENT") + plivoUuid String? @map("plivo_uuid") + error String? + sentAt DateTime @default(now()) @map("sent_at") + + @@index([userId]) + @@index([sentAt]) + @@map("sms_logs") +} + enum TransactionType { EARN SPEND diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 6fcd814..0ca314d 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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 }; diff --git a/backend/src/services/plivo.ts b/backend/src/services/plivo.ts new file mode 100644 index 0000000..c82d203 --- /dev/null +++ b/backend/src/services/plivo.ts @@ -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(); // key: "global", value: timestamp +const lastSentPerUser = new Map(); // 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 { + 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 { + 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 }, + }, + }, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 96199c8..2ebf132 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,9 @@ services: - OVERSEER_API_KEY=${OVERSEER_API_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN} + - PLIVO_AUTH_ID=${PLIVO_AUTH_ID} + - PLIVO_AUTH_TOKEN=${PLIVO_AUTH_TOKEN} + - PLIVO_SRC_NUMBER=${PLIVO_SRC_NUMBER} network_mode: host depends_on: postgres: @@ -75,14 +78,10 @@ services: dockerfile: frontend/Dockerfile args: - NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:3002} - - NEXT_PUBLIC_SOLANA_NETWORK=devnet - - NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com container_name: coop-frontend environment: - NODE_ENV=production - NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:3002} - - NEXT_PUBLIC_SOLANA_NETWORK=devnet - - NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com ports: - "3000:3000" # Exposed on all interfaces depends_on: diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index d1f495d..42d11f2 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -7,9 +7,11 @@ import { Film, Gift, List, + MessageSquare, RefreshCcw, Save, Search, + Send, Settings, ShieldAlert, TrendingUp, @@ -53,6 +55,8 @@ interface User { isAdmin: boolean; totalEarned: number; totalSpent: number; + phoneNumber?: string | null; + smsOptOut?: boolean; } interface SystemSettings { @@ -85,6 +89,11 @@ export default function AdminPage() { const [kofiPayments, setKofiPayments] = useState([]); const [requests, setRequests] = useState([]); const [searchQuery, setSearchQuery] = useState(""); + const [smsLogs, setSmsLogs] = useState([]); + const [smsMessage, setSmsMessage] = useState(""); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [smsLoading, setSmsLoading] = useState(false); + const [phoneEdits, setPhoneEdits] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [isSavingSettings, setIsSavingSettings] = useState(false); const [rewardAmount, setRewardAmount] = useState>({}); @@ -99,19 +108,21 @@ export default function AdminPage() { const loadData = async () => { try { - const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes] = + const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes, smsRes] = await Promise.all([ adminApi.getAnalytics(), adminApi.getUsers(), adminApi.getSettings(), adminApi.getKofiPayments(), adminApi.getAllRequests(), + adminApi.getSmsLogs(), ]); setAnalytics(analyticsRes.data); setUsers(usersRes.data.users || []); setSettings(settingsRes.data); setKofiPayments(kofiRes.data.payments || []); setRequests(requestsRes.data.requests || []); + setSmsLogs(smsRes.data.logs || []); } catch (err) { console.error("Admin load error:", err); toast.error("Failed to load coop secrets"); @@ -165,6 +176,54 @@ export default function AdminPage() { } }; + const updateUserPhone = async (userId: string) => { + const phone = phoneEdits[userId]; + if (phone === undefined) return; + try { + await adminApi.updateUserPhone(userId, { phoneNumber: phone || undefined }); + toast.success("Phone number updated!"); + loadData(); + setPhoneEdits((prev) => { + const next = { ...prev }; + delete next[userId]; + return next; + }); + } catch { + toast.error("Failed to update phone"); + } + }; + + const handleSendSms = async () => { + if (!smsMessage.trim()) { + toast.error("Message cannot be empty"); + return; + } + if (selectedUserIds.length === 0) { + toast.error("Select at least one chicken"); + return; + } + setSmsLoading(true); + try { + const res = await adminApi.sendSms({ + userIds: selectedUserIds, + message: smsMessage.trim(), + }); + const { sent, failed, results } = res.data; + toast.success(`Sent ${sent} squawks, ${failed} failed`); + if (failed > 0 && results) { + const firstFail = results.find((r: any) => !r.success); + if (firstFail) toast.error(firstFail.error); + } + setSmsMessage(""); + setSelectedUserIds([]); + loadData(); + } catch { + toast.error("Failed to send squawks"); + } finally { + setSmsLoading(false); + } + }; + const filteredUsers = users.filter((u) => u.plexUsername?.toLowerCase().includes(searchQuery.toLowerCase()), ); @@ -207,7 +266,7 @@ export default function AdminPage() { - + DONATIONS - - REQUESTS - + + REQUESTS + + + SQUAWK BOX + +
+ + setPhoneEdits({ + ...phoneEdits, + [u.id]: e.target.value, + }) + } + className="h-8 w-40 rounded-full border-2 border-foreground text-xs font-bold" + /> + {phoneEdits[u.id] !== undefined && + phoneEdits[u.id] !== (u.phoneNumber || "") && ( + + )} +
@@ -464,14 +556,20 @@ export default function AdminPage() {
-

Ko-fi Donations

-

Incoming feed purchases

+

+ Ko-fi Donations +

+

+ Incoming feed purchases +

{kofiPayments.length === 0 ? (
-
No donations yet. The well is dry.
+
+ No donations yet. The well is dry. +
) : (
@@ -482,8 +580,12 @@ export default function AdminPage() { >
- {p.fromName || "Anonymous"} - + + {p.fromName || "Anonymous"} + + {p.status}
@@ -514,17 +616,20 @@ export default function AdminPage() {
-
-

The Feed Queue

+

+ The Feed Queue +