"use server"; import { eq, sql } from "drizzle-orm"; import { requirePermission } from "@/lib/admin/guard"; import { db, Sanctions, User, UsersSettings } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import type { ActionResult } from "@/lib/safe-action-shared"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; export async function bulkUnban({ userIds, }: { userIds: number[]; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } }, }); await logStaffActivity({ staffId: staff.id, action: "bulk_unban", description: `Unbanned ${result.count} user(s)`, targetType: "user", }); return { ok: true as const, data: { unbanned: result.count, total: userIds.length }, }; } export async function bulkBan({ userIds, reason, duration, }: { userIds: number[]; reason: string; duration: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const now = Math.floor(Date.now() / 1000); let banned = 0; for (const userId of userIds) { try { await prisma.ban.create({ data: { userId, ip: "", machineId: "", userStaffId: staff.id, timestamp: now, banExpire: duration > 0 ? now + duration : 0, banReason: reason, type: "account", }, }); banned++; } catch { // skip duplicates } } await logStaffActivity({ staffId: staff.id, action: "bulk_ban", description: `Banned ${banned} user(s)`, targetType: "user", }); return { ok: true as const, data: { banned } }; } export async function bulkGiveCurrency({ userIds, amount, type, }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points"; }): Promise< ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }>; }> > { const staff = await requirePermission(PERMS.USERS_EDIT); let given = 0; const failedIds: Array<{ userId: number; reason: string }> = []; for (const userId of userIds) { try { if (type === "credits") { await prisma.user.update({ where: { id: userId }, data: { credits: { increment: amount } }, }); await rcon.giveCredits(userId, amount); } else if (type === "pixels") { await prisma.usersCurrency.upsert({ where: { userId_type: { userId, type: 0 } }, update: { amount: { increment: amount } }, create: { userId, type: 0, amount }, }); await rcon.giveDuckets(userId, amount); } else if (type === "points") { await prisma.usersCurrency.upsert({ where: { userId_type: { userId, type: 101 } }, update: { amount: { increment: amount } }, create: { userId, type: 101, amount }, }); await rcon.givePointsGotw(userId, amount); } given++; } catch { failedIds.push({ userId, reason: "Database error" }); } } await logStaffActivity({ staffId: staff.id, action: "bulk_give_currency", description: `Gave ${amount} ${type} to ${given} user(s)`, targetType: "user", }); return { ok: true as const, data: { given, total: userIds.length, failedIds }, }; } export async function bulkGiveBadge({ userIds, badgeCode, }: { userIds: number[]; badgeCode: string; }): Promise< ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }>; }> > { const staff = await requirePermission(PERMS.USERS_EDIT); let given = 0; const failedIds: Array<{ userId: number; reason: string }> = []; for (const userId of userIds) { try { const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode }, select: { id: true }, }); if (!existing) { const max = await prisma.usersBadges.aggregate({ where: { userId }, _max: { slotId: true }, }); const slotId = (max._max.slotId ?? 0) + 1; await prisma.usersBadges.create({ data: { userId, slotId, badgeCode }, }); await rcon.giveBadge(userId, badgeCode); } given++; } catch { failedIds.push({ userId, reason: "Database error" }); } } await logStaffActivity({ staffId: staff.id, action: "bulk_give_badge", description: `Gave badge "${badgeCode}" to ${given} user(s)`, targetType: "user", }); return { ok: true as const, data: { given, total: userIds.length, failedIds }, }; } export async function bulkAdjustCurrency({ userIds, amount, type, }: { userIds: number[]; /** Positive = give, negative = take. Balances clamped at 0. */ amount: number; type: "credits" | "pixels" | "points"; }): Promise< ActionResult<{ adjusted: number; total: number; failedIds: Array<{ userId: number; reason: string }>; }> > { const staff = await requirePermission(PERMS.USERS_EDIT); if (!Number.isFinite(amount) || amount === 0) { return { ok: false as const, error: "Amount must be a non-zero number" }; } if (amount > 0) { const given = await bulkGiveCurrency({ userIds, amount, type }); if (!given.ok) return given; if (!given.data) { return { ok: false as const, error: "Currency adjustment failed" }; } return { ok: true as const, data: { adjusted: given.data.given, total: given.data.total, failedIds: given.data.failedIds, }, }; } const take = Math.abs(Math.trunc(amount)); let adjusted = 0; const failedIds: Array<{ userId: number; reason: string }> = []; for (const userId of userIds) { try { if (type === "credits") { const user = await prisma.user.findUnique({ where: { id: userId }, select: { credits: true }, }); if (!user) { failedIds.push({ userId, reason: "Not found" }); continue; } const next = Math.max(0, user.credits - take); await prisma.user.update({ where: { id: userId }, data: { credits: next }, }); } else { const currencyType = type === "pixels" ? 0 : 101; const row = await prisma.usersCurrency.findUnique({ where: { userId_type: { userId, type: currencyType } }, }); const current = row?.amount ?? 0; const next = Math.max(0, current - take); await prisma.usersCurrency.upsert({ where: { userId_type: { userId, type: currencyType } }, update: { amount: next }, create: { userId, type: currencyType, amount: next }, }); } adjusted++; } catch { failedIds.push({ userId, reason: "Database error" }); } } await logStaffActivity({ staffId: staff.id, action: "bulk_adjust_currency", description: `Adjusted ${amount} ${type} for ${adjusted} user(s) (DB-only take; no RCON debit)`, targetType: "user", }); return { ok: true as const, data: { adjusted, total: userIds.length, failedIds }, }; } /** * Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade` * via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online). */ export async function setTradeLock({ userId, untilUnix, }: { userId: number; /** Unix seconds; 0 clears the lock. */ untilUnix: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const until = Math.max(0, Math.trunc(untilUnix)); const locked = until > 0; const [user] = await db .select({ id: User.id, username: User.username, online: User.online, }) .from(User) .where(eq(User.id, userId)) .limit(1); if (!user) { return { ok: false as const, error: "User not found" }; } await db.transaction(async (tx) => { const [existing] = await tx .select({ id: Sanctions.id }) .from(Sanctions) .where(eq(Sanctions.habboId, userId)) .limit(1); if (existing) { await tx .update(Sanctions) .set({ tradeLockedUntil: until, ...(locked ? { reason: "Trade lock (CMS)" } : {}), }) .where(eq(Sanctions.id, existing.id)); } else { await tx.insert(Sanctions).values({ habboId: userId, tradeLockedUntil: until, reason: locked ? "Trade lock (CMS)" : "", }); } await tx .update(UsersSettings) .set({ canTrade: locked ? "0" : "1", ...(locked ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } : {}), }) .where(eq(UsersSettings.userId, userId)); }); await rcon.setTradeLock(userId, locked); await rcon.alertUser( userId, locked ? "Trading has been disabled by staff." : "Trading has been re-enabled by staff.", ); if (user.online === "1") { await rcon.disconnectUser(userId, user.username); } await logStaffActivity({ staffId: staff.id, action: locked ? "trade_lock" : "trade_unlock", description: locked ? `Trade-locked ${user.username} (#${userId}) until ${until}` : `Cleared trade lock for ${user.username} (#${userId})`, targetType: "user", targetId: userId, }); return { ok: true as const, data: { userId, untilUnix: until } }; }