feat: add Plivo SMS admin panel with rate limits
This commit is contained in:
@@ -45,6 +45,15 @@ OVERSEER_API_KEY=""
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
KOFI_VERIFICATION_TOKEN=""
|
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)
|
# Redis (optional caching)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ model User {
|
|||||||
totalEarned Int @default(0) @map("total_earned")
|
totalEarned Int @default(0) @map("total_earned")
|
||||||
totalSpent Int @default(0) @map("total_spent")
|
totalSpent Int @default(0) @map("total_spent")
|
||||||
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
||||||
|
phoneNumber String? @map("phone_number")
|
||||||
|
smsOptOut Boolean @default(false) @map("sms_opt_out")
|
||||||
|
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
watchEvents WatchEvent[]
|
watchEvents WatchEvent[]
|
||||||
contentRequests ContentRequest[]
|
contentRequests ContentRequest[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
kofiPayments KofiPayment[]
|
kofiPayments KofiPayment[]
|
||||||
|
smsLogs SmsLog[]
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -140,6 +143,22 @@ model KofiPayment {
|
|||||||
@@map("kofi_payments")
|
@@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 {
|
enum TransactionType {
|
||||||
EARN
|
EARN
|
||||||
SPEND
|
SPEND
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
requireAdmin,
|
requireAdmin,
|
||||||
} from "../middleware/auth";
|
} from "../middleware/auth";
|
||||||
import { asyncHandler } from "../middleware/errorHandler";
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
|
import { getSmsLogs, sendSms, sendSmsToUsers } from "../services/plivo";
|
||||||
import { backfillUserHistory } from "../services/tautulli";
|
import { backfillUserHistory } from "../services/tautulli";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ router.get(
|
|||||||
totalEarned: true,
|
totalEarned: true,
|
||||||
totalSpent: true,
|
totalSpent: true,
|
||||||
watchTimeMinutes: true,
|
watchTimeMinutes: true,
|
||||||
|
phoneNumber: true,
|
||||||
|
smsOptOut: true,
|
||||||
createdAt: 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 };
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-4
@@ -61,6 +61,9 @@ services:
|
|||||||
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KOFI_VERIFICATION_TOKEN=${KOFI_VERIFICATION_TOKEN}
|
- 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
|
network_mode: host
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -75,14 +78,10 @@ services:
|
|||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:3002}
|
- 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
|
container_name: coop-frontend
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:3002}
|
- 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:
|
ports:
|
||||||
- "3000:3000" # Exposed on all interfaces
|
- "3000:3000" # Exposed on all interfaces
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+236
-19
@@ -7,9 +7,11 @@ import {
|
|||||||
Film,
|
Film,
|
||||||
Gift,
|
Gift,
|
||||||
List,
|
List,
|
||||||
|
MessageSquare,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -53,6 +55,8 @@ interface User {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
totalEarned: number;
|
totalEarned: number;
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
smsOptOut?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SystemSettings {
|
interface SystemSettings {
|
||||||
@@ -85,6 +89,11 @@ export default function AdminPage() {
|
|||||||
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
||||||
const [requests, setRequests] = useState<any[]>([]);
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [smsLogs, setSmsLogs] = useState<any[]>([]);
|
||||||
|
const [smsMessage, setSmsMessage] = useState("");
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||||
|
const [smsLoading, setSmsLoading] = useState(false);
|
||||||
|
const [phoneEdits, setPhoneEdits] = useState<Record<string, string>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
const [rewardAmount, setRewardAmount] = useState<Record<string, string>>({});
|
const [rewardAmount, setRewardAmount] = useState<Record<string, string>>({});
|
||||||
@@ -99,19 +108,21 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes] =
|
const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes, smsRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
adminApi.getAnalytics(),
|
adminApi.getAnalytics(),
|
||||||
adminApi.getUsers(),
|
adminApi.getUsers(),
|
||||||
adminApi.getSettings(),
|
adminApi.getSettings(),
|
||||||
adminApi.getKofiPayments(),
|
adminApi.getKofiPayments(),
|
||||||
adminApi.getAllRequests(),
|
adminApi.getAllRequests(),
|
||||||
|
adminApi.getSmsLogs(),
|
||||||
]);
|
]);
|
||||||
setAnalytics(analyticsRes.data);
|
setAnalytics(analyticsRes.data);
|
||||||
setUsers(usersRes.data.users || []);
|
setUsers(usersRes.data.users || []);
|
||||||
setSettings(settingsRes.data);
|
setSettings(settingsRes.data);
|
||||||
setKofiPayments(kofiRes.data.payments || []);
|
setKofiPayments(kofiRes.data.payments || []);
|
||||||
setRequests(requestsRes.data.requests || []);
|
setRequests(requestsRes.data.requests || []);
|
||||||
|
setSmsLogs(smsRes.data.logs || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin load error:", err);
|
console.error("Admin load error:", err);
|
||||||
toast.error("Failed to load coop secrets");
|
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) =>
|
const filteredUsers = users.filter((u) =>
|
||||||
u.plexUsername?.toLowerCase().includes(searchQuery.toLowerCase()),
|
u.plexUsername?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
@@ -207,7 +266,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="flock" className="w-full">
|
<Tabs defaultValue="flock" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-5 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
<TabsList className="grid w-full grid-cols-6 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="flock"
|
value="flock"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -226,12 +285,18 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="requests"
|
value="requests"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
>
|
>
|
||||||
<Film className="mr-2 h-4 w-4" /> REQUESTS
|
<Film className="mr-2 h-4 w-4" /> REQUESTS
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="squawk"
|
||||||
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
|
>
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" /> SQUAWK BOX
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="stats"
|
value="stats"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -281,6 +346,33 @@ export default function AdminPage() {
|
|||||||
Nest Egg: {formatNumber(u.totalEarned - u.totalSpent)}{" "}
|
Nest Egg: {formatNumber(u.totalEarned - u.totalSpent)}{" "}
|
||||||
$COOP
|
$COOP
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Phone #"
|
||||||
|
value={
|
||||||
|
phoneEdits[u.id] !== undefined
|
||||||
|
? phoneEdits[u.id]
|
||||||
|
: u.phoneNumber || ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
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 || "") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full font-black px-3"
|
||||||
|
onClick={() => updateUserPhone(u.id)}
|
||||||
|
>
|
||||||
|
SAVE
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -464,14 +556,20 @@ export default function AdminPage() {
|
|||||||
<TabsContent value="donations" className="focus-visible:outline-none">
|
<TabsContent value="donations" className="focus-visible:outline-none">
|
||||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="font-display text-3xl uppercase italic">Ko-fi Donations</h2>
|
<h2 className="font-display text-3xl uppercase italic">
|
||||||
<p className="text-sm font-bold text-muted-foreground">Incoming feed purchases</p>
|
Ko-fi Donations
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">
|
||||||
|
Incoming feed purchases
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kofiPayments.length === 0 ? (
|
{kofiPayments.length === 0 ? (
|
||||||
<div className="text-center py-10 opacity-40">
|
<div className="text-center py-10 opacity-40">
|
||||||
<div className="text-6xl mb-4">☕</div>
|
<div className="text-6xl mb-4">☕</div>
|
||||||
<div className="font-black uppercase tracking-widest text-sm">No donations yet. The well is dry.</div>
|
<div className="font-black uppercase tracking-widest text-sm">
|
||||||
|
No donations yet. The well is dry.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -482,8 +580,12 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-black text-lg">{p.fromName || "Anonymous"}</span>
|
<span className="font-black text-lg">
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${p.status === "CREDITED" ? "bg-primary text-white" : "bg-destructive/20 text-destructive"}`}>
|
{p.fromName || "Anonymous"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${p.status === "CREDITED" ? "bg-primary text-white" : "bg-destructive/20 text-destructive"}`}
|
||||||
|
>
|
||||||
{p.status}
|
{p.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,17 +616,20 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
||||||
<TabsContent value="requests" className="focus-visible:outline-none">
|
<TabsContent value="requests" className="focus-visible:outline-none">
|
||||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="font-display text-3xl uppercase italic">The Feed Queue</h2>
|
<h2 className="font-display text-3xl uppercase italic">
|
||||||
|
The Feed Queue
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
toast.info("Checking with Overseer...");
|
toast.info("Checking with Overseer...");
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.syncAllRequests();
|
const res = await adminApi.syncAllRequests();
|
||||||
toast.success(`Synced ${res.data.updated?.length || 0} requests`);
|
toast.success(
|
||||||
|
`Synced ${res.data.updated?.length || 0} requests`,
|
||||||
|
);
|
||||||
loadData();
|
loadData();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Sync failed");
|
toast.error("Sync failed");
|
||||||
@@ -540,7 +645,9 @@ export default function AdminPage() {
|
|||||||
{requests.length === 0 ? (
|
{requests.length === 0 ? (
|
||||||
<div className="text-center py-10 opacity-40">
|
<div className="text-center py-10 opacity-40">
|
||||||
<div className="text-6xl mb-4">📭</div>
|
<div className="text-6xl mb-4">📭</div>
|
||||||
<div className="font-black uppercase tracking-widest text-sm">No requests in the queue.</div>
|
<div className="font-black uppercase tracking-widest text-sm">
|
||||||
|
No requests in the queue.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -552,12 +659,16 @@ export default function AdminPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-black text-lg">{r.title}</span>
|
<span className="font-black text-lg">{r.title}</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${r.status === "APPROVED" ? "bg-primary text-white" : r.status === "DECLINED" ? "bg-destructive text-white" : "bg-secondary text-secondary-foreground"}`}>
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${r.status === "APPROVED" ? "bg-primary text-white" : r.status === "DECLINED" ? "bg-destructive text-white" : "bg-secondary text-secondary-foreground"}`}
|
||||||
|
>
|
||||||
{r.status}
|
{r.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold text-muted-foreground mt-1">
|
<div className="text-sm font-bold text-muted-foreground mt-1">
|
||||||
{r.user?.plexUsername || "Unknown"} • {r.mediaType === "movie" ? "Movie" : "TV"} • {formatNumber(r.creditsCost)} $COOP
|
{r.user?.plexUsername || "Unknown"} •{" "}
|
||||||
|
{r.mediaType === "movie" ? "Movie" : "TV"} •{" "}
|
||||||
|
{formatNumber(r.creditsCost)} $COOP
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -571,6 +682,112 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="squawk" className="focus-visible:outline-none space-y-6">
|
||||||
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="font-display text-3xl uppercase italic">Squawk Box</h2>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">Send clucks to the flock</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border-4 border-foreground bg-accent/5 p-4">
|
||||||
|
<h3 className="font-black uppercase text-sm mb-3">Select Chickens ({selectedUserIds.length})</h3>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
|
||||||
|
{users.filter(u => u.phoneNumber).map((u) => (
|
||||||
|
<label
|
||||||
|
key={u.id}
|
||||||
|
className={`flex items-center gap-2 rounded-full border-2 px-3 py-1.5 cursor-pointer transition-all ${selectedUserIds.includes(u.id) ? 'border-primary bg-primary/10' : 'border-foreground/20'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUserIds.includes(u.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedUserIds([...selectedUserIds, u.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedUserIds(selectedUserIds.filter(id => id !== u.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-black">{u.plexUsername}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{u.phoneNumber}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{users.filter(u => u.phoneNumber).length === 0 && (
|
||||||
|
<div className="text-sm font-bold text-muted-foreground">No chickens have phone numbers yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black uppercase text-muted-foreground">Message</label>
|
||||||
|
<textarea
|
||||||
|
value={smsMessage}
|
||||||
|
onChange={(e) => setSmsMessage(e.target.value)}
|
||||||
|
placeholder="Cluck cluck..."
|
||||||
|
maxLength={1600}
|
||||||
|
className="w-full min-h-[120px] rounded-2xl border-4 border-foreground bg-background p-4 font-bold focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none resize-y"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-xs font-bold text-muted-foreground">
|
||||||
|
{smsMessage.length}/1600
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSendSms}
|
||||||
|
disabled={smsLoading || selectedUserIds.length === 0 || !smsMessage.trim()}
|
||||||
|
className="w-full h-14 rounded-full font-black text-xl shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none transition-all border-4 border-foreground"
|
||||||
|
>
|
||||||
|
{smsLoading ? "SENDING..." : "SEND SQUAWK"}
|
||||||
|
<Send className="ml-2 h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="font-display text-3xl uppercase italic">Squawk History</h2>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">Previously sent messages</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{smsLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 opacity-40">
|
||||||
|
<div className="text-6xl mb-4">📵</div>
|
||||||
|
<div className="font-black uppercase tracking-widest text-sm">No squawks sent yet.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{smsLogs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-2xl border-4 border-foreground bg-accent/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-black text-lg">{log.user?.plexUsername || log.phoneNumber}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${log.status === "SENT" ? "bg-primary text-white" : "bg-destructive text-white"}`}>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{log.message}
|
||||||
|
</div>
|
||||||
|
{log.error && (
|
||||||
|
<div className="text-xs font-bold text-destructive mt-0.5">{log.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||||
|
{new Date(log.sentAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="stats" className="focus-visible:outline-none">
|
<TabsContent value="stats" className="focus-visible:outline-none">
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ export const adminApi = {
|
|||||||
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
||||||
getAllRequests: () => api.get("/admin/requests"),
|
getAllRequests: () => api.get("/admin/requests"),
|
||||||
syncAllRequests: () => api.post("/admin/sync-requests"),
|
syncAllRequests: () => api.post("/admin/sync-requests"),
|
||||||
|
updateUserPhone: (
|
||||||
|
id: string,
|
||||||
|
data: { phoneNumber?: string; smsOptOut?: boolean },
|
||||||
|
) => api.put(`/admin/users/${id}/phone`, data),
|
||||||
|
sendSms: (data: {
|
||||||
|
userIds?: string[];
|
||||||
|
phoneNumbers?: string[];
|
||||||
|
message: string;
|
||||||
|
}) => api.post("/admin/sms/send", data),
|
||||||
|
getSmsLogs: () => api.get("/admin/sms/logs"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Overseer API
|
// Overseer API
|
||||||
|
|||||||
Reference in New Issue
Block a user