"use server"; import { and, eq } from "drizzle-orm"; import { createPeopleMutationInvocation, peopleMutationService, } from "@/features/housekeeping/domains/people/services/mutations"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; import { db, User, UsersCurrency } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; import { logStaffActivity } from "@/lib/services/staff-activity"; async function executeLegacy( staff: { readonly id: number; readonly username: string; readonly rank: number; }, operation: | "users.bulk-ban" | "users.bulk-unban" | "users.bulk-currency" | "users.bulk-badge" | "user.trade-lock", input: unknown, ) { return peopleMutationService.execute( createPeopleMutationInvocation(staff, createCorrelationId()), operation, input, ); } function numberValue(value: unknown): number { return Number.isSafeInteger(Number(value)) ? Number(value) : 0; } function failedIds(value: unknown): Array<{ userId: number; reason: string }> { return Array.isArray(value) ? value.flatMap((item) => typeof item === "object" && item !== null ? [ { userId: numberValue(Reflect.get(item, "userId")), reason: String(Reflect.get(item, "reason") ?? "Database error"), }, ] : [], ) : []; } function legacyBulkOutcome( after: Readonly> | null, userIds: readonly number[], ): { readonly completed: number; readonly total: number; readonly failedIds: Array<{ userId: number; reason: string }>; } { const databaseFailures = failedIds(after?.failedIds); const externalSyncFailures = failedIds(after?.externalSyncFailures).map( ({ userId }) => ({ userId, reason: "Database error" }), ); const pendingFailures = [...databaseFailures, ...externalSyncFailures]; const orderedFailures = userIds.flatMap((userId) => { const index = pendingFailures.findIndex( (failure) => failure.userId === userId, ); return index === -1 ? [] : pendingFailures.splice(index, 1); }); return { completed: Math.max( 0, numberValue(after?.completed) - externalSyncFailures.length, ), total: numberValue(after?.total), failedIds: [...orderedFailures, ...pendingFailures], }; } export async function bulkUnban({ userIds, }: { userIds: number[]; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const result = await executeLegacy(staff, "users.bulk-unban", { userIds }); if (!result.ok) return { ok: false, error: "Bulk unban failed" }; return { ok: true, data: { unbanned: numberValue(result.data.after?.completed), total: numberValue(result.data.after?.total), }, }; } export async function bulkBan({ userIds, reason, duration, }: { userIds: number[]; reason: string; duration: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const result = await executeLegacy(staff, "users.bulk-ban", { userIds, reason, duration, }); if (!result.ok) return { ok: false, error: "Bulk ban failed" }; return { ok: true, data: { banned: numberValue(result.data.after?.completed) }, }; } 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); const result = await executeLegacy(staff, "users.bulk-currency", { userIds, amount, type, }); if (!result.ok) return { ok: false, error: "Bulk currency failed" }; const outcome = legacyBulkOutcome(result.data.after, userIds); return { ok: true, data: { given: outcome.completed, total: outcome.total, failedIds: outcome.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); const result = await executeLegacy(staff, "users.bulk-badge", { userIds, badgeCode, }); if (!result.ok) return { ok: false, error: "Bulk badge failed" }; const outcome = legacyBulkOutcome(result.data.after, userIds); return { ok: true, data: { given: outcome.completed, total: outcome.total, failedIds: outcome.failedIds, }, }; } export async function bulkAdjustCurrency({ userIds, amount, type, }: { userIds: number[]; 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, error: "Amount must be a non-zero number" }; } if (amount > 0) { const result = await executeLegacy(staff, "users.bulk-currency", { userIds, amount, type, }); if (!result.ok) return { ok: false, error: "Currency adjustment failed" }; const outcome = legacyBulkOutcome(result.data.after, userIds); return { ok: true, data: { adjusted: outcome.completed, total: outcome.total, failedIds: outcome.failedIds, }, }; } const take = Math.abs(Math.trunc(amount)); let adjusted = 0; const failures: Array<{ userId: number; reason: string }> = []; for (const userId of userIds) { try { if (type === "credits") { const [user] = await db .select({ credits: User.credits }) .from(User) .where(eq(User.id, userId)) .limit(1); if (!user) { failures.push({ userId, reason: "Not found" }); continue; } await db .update(User) .set({ credits: Math.max(0, user.credits - take) }) .where(eq(User.id, userId)); } else { const currencyType = type === "pixels" ? 0 : 101; const [row] = await db .select({ amount: UsersCurrency.amount }) .from(UsersCurrency) .where( and( eq(UsersCurrency.userId, userId), eq(UsersCurrency.type, currencyType), ), ) .limit(1); const next = Math.max(0, (row?.amount ?? 0) - take); await db .insert(UsersCurrency) .values({ userId, type: currencyType, amount: next }) .onDuplicateKeyUpdate({ set: { amount: next } }); } adjusted += 1; } catch { failures.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, data: { adjusted, total: userIds.length, failedIds: failures }, }; } export async function setTradeLock({ userId, untilUnix, }: { userId: number; untilUnix: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const until = Math.max(0, Math.trunc(untilUnix)); const result = await executeLegacy(staff, "user.trade-lock", { userId, untilUnix: until, }); if (!result.ok) { return { ok: false, error: result.error.code === "NOT_FOUND" ? "User not found" : "Trade lock update failed", }; } return { ok: true, data: { userId, untilUnix: until } }; }