feat(admin): ban appeals, photos polish, economy adjust, staff smoke
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m17s
CI / check (push) Successful in 25s
CI / deploy (push) Failing after 11s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-30 20:23:03 +02:00
1 parent fe46bd0544
commit 3ad3f9512c
36 files changed
+1185 -293

No files matched your search

+47
View File
@@ -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(
+30 -1
View File
@@ -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");
}
+136
View File
@@ -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 } };
}
+50 -1
View File
@@ -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,