feat(admin): ban appeals, photos polish, economy adjust, staff smoke
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
fe46bd0544
commit
3ad3f9512c
36 files changed
+1185
-293
No files matched your search
@@ -31,6 +31,53 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
|
||||
export const liftBanFromHelpTicket = adminAction(
|
||||
{
|
||||
permission: PERMS.USERS_BAN,
|
||||
schema: helpCenterTicketIdSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true, title: true },
|
||||
});
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.userId == null) {
|
||||
throw new ActionError("Ticket has no requester to unban");
|
||||
}
|
||||
|
||||
const removed = await prisma.ban.deleteMany({
|
||||
where: { userId: ticket.userId },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
if (ticket.open) {
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban_via_help_ticket",
|
||||
target: "User",
|
||||
targetId: ticket.userId,
|
||||
after: {
|
||||
ticketId: String(ticketId),
|
||||
removedBans: removed.count,
|
||||
title: ticket.title,
|
||||
},
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||
return actionOk({ removed: removed.count, userId: ticket.userId });
|
||||
},
|
||||
);
|
||||
|
||||
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
|
||||
@@ -4,17 +4,46 @@ import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
try {
|
||||
await prisma.cameraWeb.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_delete",
|
||||
description: `Deleted camera photo #${id}`,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
} catch {
|
||||
// Record may have already been removed; ignore.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/photos");
|
||||
}
|
||||
|
||||
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const raw = String(formData.get("ids") ?? "");
|
||||
const ids = raw
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const result = await prisma.cameraWeb.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_bulk_delete",
|
||||
description: `Deleted ${result.count} camera photo(s)`,
|
||||
targetType: "camera_web",
|
||||
});
|
||||
revalidatePath("/admin/photos");
|
||||
}
|
||||
@@ -181,3 +181,139 @@ export async function bulkGiveBadge({
|
||||
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`.
|
||||
* No first-class RCON trade-lock helper in this CMS — DB only.
|
||||
*/
|
||||
export async function setTradeLock({
|
||||
userId,
|
||||
untilUnix,
|
||||
}: {
|
||||
userId: number;
|
||||
/** Unix seconds; 0 clears the lock. */
|
||||
untilUnix: number;
|
||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const until = Math.max(0, Math.trunc(untilUnix));
|
||||
|
||||
const existing = await prisma.sanctions.findFirst({
|
||||
where: { habboId: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.sanctions.update({
|
||||
where: { id: existing.id },
|
||||
data: { tradeLockedUntil: until },
|
||||
});
|
||||
} else {
|
||||
await prisma.sanctions.create({
|
||||
data: {
|
||||
habboId: userId,
|
||||
tradeLockedUntil: until,
|
||||
reason: until > 0 ? "Trade lock (CMS)" : "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: until > 0 ? "trade_lock" : "trade_unlock",
|
||||
description:
|
||||
until > 0
|
||||
? `Trade-locked user #${userId} until ${until}`
|
||||
: `Cleared trade lock for user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||
}
|
||||
@@ -99,12 +99,61 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const banAppeal =
|
||||
String(formData.get("banAppeal") ?? "") === "1" ||
|
||||
String(formData.get("banAppeal") ?? "") === "on";
|
||||
let ticketTitle = title;
|
||||
let categoryId: bigint | null = null;
|
||||
|
||||
if (banAppeal) {
|
||||
if (!/ban\s*appeal/i.test(ticketTitle)) {
|
||||
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
|
||||
}
|
||||
const existing = await prisma.websiteHelpCenterCategories.findFirst(
|
||||
{
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
},
|
||||
);
|
||||
if (existing) {
|
||||
categoryId = existing.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await prisma.websiteHelpCenterCategories.create(
|
||||
{
|
||||
data: {
|
||||
name: "Ban appeal",
|
||||
content:
|
||||
"Appeals for account bans. Staff can lift bans from the ticket.",
|
||||
position: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
);
|
||||
categoryId = created.id;
|
||||
} catch {
|
||||
const again =
|
||||
await prisma.websiteHelpCenterCategories.findFirst({
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
});
|
||||
categoryId = again?.id ?? null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const rawCat = String(formData.get("categoryId") ?? "").trim();
|
||||
if (/^\d+$/.test(rawCat)) {
|
||||
categoryId = BigInt(rawCat);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
title: ticketTitle,
|
||||
content,
|
||||
categoryId,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
Reference in new issue
Block a user