feat(housekeeping): deliver people account workflows

This commit is contained in:
Simo committed 2026-08-29 11:45:50 +02:00
1 parent 7920d4f46c
commit e1b31ff773
45 files changed
+4290 -1243

No files matched your search

+20 -13
View File
@@ -1,24 +1,31 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
const staff = await requirePermission(PERMS.USERS_EDIT);
const rawId = formPositiveBigInt(formData, "id");
if (!rawId) return;
const applicationId = Number(rawId);
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"application.decide",
{ applicationId, decision: "dismiss" },
);
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
revalidatePath("/admin/applications");
}
+30 -71
View File
@@ -1,91 +1,50 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { disbandGuild } from "./admin-guilds";
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
() => {
const selectLimit = vi.fn();
const transactionFn = vi.fn();
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
return { selectLimit, transactionFn, deleteWhere, updateSet };
},
);
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
transaction: transactionFn,
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: updateSet })),
},
Guilds: { id: "id", name: "name", userId: "userId" },
GuildsForumsThreads: { id: "id", guildId: "guildId" },
GuildsForumsComments: { threadId: "threadId" },
GuildForumViews: { guildId: "guildId" },
GuildsMembers: { guildId: "guildId" },
Rooms: { guildId: "guildId" },
Items: { guildId: "guildId" },
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
execute.mockResolvedValue({
ok: true,
data: { before: { id: 1 }, after: null },
correlationId: "guild",
});
});
describe("disbandGuild", () => {
it("disbands guild and cleans related data", async () => {
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
transactionFn.mockImplementation(
async (fn: (tx: unknown) => Promise<void>) => {
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: txSelectLimit,
})),
})),
})),
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
})),
};
// For threads findMany (no limit) — make where resolve to array
tx.select = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ id: 10 }]),
})),
}));
await fn(tx);
},
);
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
expect(logStaffActivity).toHaveBeenCalled();
describe("disbandGuild legacy wrapper", () => {
it("keeps rate-limited ACL, service input, and /admin revalidation", async () => {
await disbandGuild(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
guildId: 9,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
});
it("returns early when id is not positive", async () => {
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
expect(selectLimit).not.toHaveBeenCalled();
it("keeps invalid IDs as a no-op", async () => {
await disbandGuild(form({ id: "0" }));
expect(execute).not.toHaveBeenCalled();
expect(revalidatePath).not.toHaveBeenCalled();
});
});
+20 -53
View File
@@ -1,65 +1,32 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const guildId = Number(formData.get("id"));
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) return;
await db.transaction(async (tx) => {
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
}
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "guild_disband",
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
targetType: "guild",
targetId: id,
});
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"guild.disband",
{ guildId },
);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") return;
throw new Error("Could not disband guild");
}
revalidatePath("/admin/guilds");
}
+41 -63
View File
@@ -1,6 +1,5 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import {
addBlacklist,
@@ -9,80 +8,59 @@ import {
deleteWhitelist,
} from "./admin-ip";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteIpWhitelist: { id: "id" },
WebsiteIpBlacklist: { id: "id" },
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("addWhitelist", () => {
it("creates whitelist entry", async () => {
await addWhitelist(
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "192.168.1.1",
asn: null,
whitelistAsn: false,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
});
it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
execute.mockResolvedValue({
ok: true,
data: { before: null, after: {} },
correlationId: "ip",
});
});
describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
});
it("preserves all four IP actions and /admin revalidation", async () => {
await addWhitelist(form({ ipAddress: "192.0.2.1", asn: "AS1" }));
await addBlacklist(form({ ipAddress: "198.51.100.1" }));
await deleteWhitelist(form({ id: "42" }));
await deleteBlacklist(form({ id: "99" }));
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
[
"ip.action",
{ action: "add-whitelist", ipAddress: "192.0.2.1", asn: "AS1" },
],
[
"ip.action",
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
],
["ip.action", { action: "delete-whitelist", id: 42 }],
["ip.action", { action: "delete-blacklist", id: 99 }],
]);
expect(revalidatePath).toHaveBeenCalledTimes(4);
});
describe("addBlacklist", () => {
it("creates blacklist entry", async () => {
await addBlacklist(
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "203.0.113.1",
asn: null,
blacklistAsn: false,
});
});
});
describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
});
it("keeps empty IP input as a no-op after authorization", async () => {
await addWhitelist(form({ ipAddress: "" }));
expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit");
expect(execute).not.toHaveBeenCalled();
});
+42 -51
View File
@@ -1,72 +1,63 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "")
function parse(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
}
function parseAsn(formData: FormData): string | null {
const asn = String(formData.get("asn") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
return asn || null;
async function run(
formData: FormData,
action:
| "add-whitelist"
| "delete-whitelist"
| "add-blacklist"
| "delete-blacklist",
): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const adding = action.startsWith("add-");
const input = adding
? {
action,
ipAddress: parse(formData, "ipAddress"),
asn: parse(formData, "asn"),
}
: { action, id: Number(formData.get("id")) };
if (adding && !("ipAddress" in input && input.ipAddress)) return;
const id = "id" in input ? input.id : undefined;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
"ip.action",
input,
);
if (!result.ok) throw new Error("Could not update IP rules");
revalidatePath("/admin/ip");
}
export async function addWhitelist(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpWhitelist).values({
ipAddress,
asn,
whitelistAsn: asn != null,
});
revalidatePath("/admin/ip");
return run(formData, "add-whitelist");
}
export async function deleteWhitelist(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpWhitelist)
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
revalidatePath("/admin/ip");
return run(formData, "delete-whitelist");
}
export async function addBlacklist(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpBlacklist).values({
ipAddress,
asn,
blacklistAsn: asn != null,
});
revalidatePath("/admin/ip");
return run(formData, "add-blacklist");
}
export async function deleteBlacklist(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpBlacklist)
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
revalidatePath("/admin/ip");
return run(formData, "delete-blacklist");
}
+47 -43
View File
@@ -1,59 +1,63 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { createTeam, deleteTeam } from "./admin-teams";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteTeams: { id: "id" },
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createTeam", () => {
it("creates a team entry", async () => {
await createTeam(
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ rankName: "Moderator" }),
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
execute.mockResolvedValue({
ok: true,
data: { before: null, after: {} },
correlationId: "team",
});
});
describe("deleteTeam", () => {
it("deletes a team entry", async () => {
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
it("preserves create and delete team payloads plus /admin revalidation", async () => {
await createTeam(form({ rankName: "Moderator" }));
await deleteTeam(form({ id: "42" }));
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
[
"team.change",
{
action: "create",
rankName: "Moderator",
badge: "",
jobDescription: "",
staffColor: "#327fa8",
hiddenRank: false,
},
],
["team.change", { action: "delete", teamId: 42 }],
]);
expect(revalidatePath).toHaveBeenCalledTimes(2);
});
it("preserves empty rank name as a no-op", async () => {
await createTeam(form({ rankName: "" }));
expect(execute).not.toHaveBeenCalled();
});
+45 -36
View File
@@ -1,50 +1,59 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
}
export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC")
.trim();
const staff = await requirePermission(PERMS.USERS_EDIT);
const rankName = text(formData, "rankName");
if (!rankName) return;
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim();
const jobDescription = String(formData.get("jobDescription") ?? "")
.normalize("NFC")
.trim();
const staffColor =
String(formData.get("staffColor") ?? "")
.normalize("NFC")
.trim() || "#327fa8";
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change",
{
action: "create",
rankName,
badge: text(formData, "badge"),
jobDescription: text(formData, "jobDescription"),
staffColor: text(formData, "staffColor") || "#327fa8",
hiddenRank: formData.get("hiddenRank") === "on",
},
);
if (!result.ok) throw new Error("Could not create team");
revalidatePath("/admin/teams");
}
export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id")));
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
const staff = await requirePermission(PERMS.USERS_EDIT);
const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change",
{ action: "delete", teamId },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not delete team");
}
revalidatePath("/admin/teams");
}
+54 -42
View File
@@ -1,60 +1,72 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { siteSettings } from "@/lib/services/site-settings";
import { saveVpn } from "./admin-vpn";
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return { mockValues, mockOnDuplicateKeyUpdate };
});
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: {}, after: {} },
correlationId: "vpn",
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
});
describe("saveVpn", () => {
it("saves VPN settings and redirects", async () => {
await saveVpn(
fakeForm({
vpn_block_enabled: "1",
vpn_provider: "proxycheck",
vpn_api_key: "abc123",
}) as unknown as FormData,
);
expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
expect(siteSettings.reload).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
it("preserves VPN payload, /admin revalidation, and redirect", async () => {
await saveVpn(
form({
vpn_block_enabled: "1",
vpn_provider: "proxycheck",
vpn_api_key: "abc123",
}),
);
expect(execute).toHaveBeenCalledWith(expect.anything(), "vpn.configure", {
enabled: true,
provider: "proxycheck",
apiKey: "abc123",
blockMessage: "",
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
});
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
execute.mockResolvedValue({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "vpn-fail",
});
await saveVpn(form({ vpn_provider: "none" }));
expect(revalidatePath).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
});
+28 -72
View File
@@ -2,88 +2,44 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
// VPN / proxy detection config. Stored as website_settings key/value rows
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
// AtomCMS's setting() convention. This is registration-time protection only;
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
/** Upsert one website_settings key with a stable housekeeping comment. */
async function writeSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveVpn(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
const enabled =
String(formData.get("vpn_block_enabled") ?? "")
.normalize("NFC")
.trim() !== "";
const providerRaw = String(formData.get("vpn_provider") ?? "")
const rawProvider = String(formData.get("vpn_provider") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
const apiKey = String(formData.get("vpn_api_key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const blockMessage = String(formData.get("vpn_block_message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
try {
await writeSetting(
"vpn_block_enabled",
enabled ? "1" : "0",
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
);
await writeSetting(
"vpn_provider",
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
"vpn.configure",
{
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
provider,
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
);
await writeSetting(
"vpn_api_key",
apiKey,
"API key for the VPN/proxy detection provider",
);
await writeSetting(
"vpn_block_message",
blockMessage,
"Message shown to users blocked for using a VPN/proxy",
);
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "vpn_update",
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
});
revalidatePath("/admin/vpn");
} catch {
// DB unavailable — fail soft so the action does not throw; the page
// re-renders the current (stored) state.
}
apiKey: String(formData.get("vpn_api_key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
blockMessage: String(formData.get("vpn_block_message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
},
);
if (result.ok) revalidatePath("/admin/vpn");
// Preserve fail-soft legacy navigation even when persistence is unavailable.
redirect("/admin/vpn?saved=1");
}
+34 -30
View File
@@ -1,56 +1,60 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
export async function addWord(input: {
word: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.WORDFILTER_EDIT);
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(input.word ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!word) return actionError("Word is required");
try {
const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.insertId) });
} catch {
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update",
{ action: "add", word },
);
if (!result.ok)
return actionError("Could not add word (it may already exist)");
}
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.data.after?.id ?? "") });
}
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(input.id ?? "").normalize("NFC");
if (!raw) return actionError("Missing word id");
try {
await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk();
} catch {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const id = Number(String(input.id ?? "").normalize("NFC"));
if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update",
{ action: "delete", id },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
return actionError("Could not remove word");
}
revalidatePath("/admin/wordfilter");
return actionOk();
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
import { bulkAdjustCurrency } from "./bulk-users";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
} as never);
execute.mockResolvedValue({
ok: true,
data: {
before: { userIds: [7, 8] },
after: { completed: 2, total: 2, failedIds: [] },
},
correlationId: "bulk-adjust",
});
});
it("keeps one ACL check while delegating a positive bulk adjustment", async () => {
await expect(
bulkAdjustCurrency({
userIds: [7, 8],
amount: 25,
type: "credits",
}),
).resolves.toEqual({
ok: true,
data: { adjusted: 2, total: 2, failedIds: [] },
});
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"users.bulk-currency",
{ userIds: [7, 8], amount: 25, type: "credits" },
);
});
+66 -150
View File
@@ -1,95 +1,32 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
import {
bulkBan,
bulkGiveBadge,
bulkGiveCurrency,
bulkUnban,
setTradeLock,
} from "./bulk-users";
const {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
} = vi.hoisted(() => {
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const selectLimit = vi.fn().mockResolvedValue([]);
/** Rows returned when a select chain is awaited without `.limit()`. */
const selectWhereResolved = vi.fn().mockResolvedValue([]);
return {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
};
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: updateWhere })),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return selectWhereResolved().then(resolve, reject);
},
})),
})),
})),
transaction: vi.fn(),
},
Ban: { userId: "userId", id: "id" },
User: {
id: "id",
credits: "credits",
username: "username",
online: "online",
},
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
UsersBadges: {
id: "id",
userId: "userId",
badgeCode: "badgeCode",
slotId: "slotId",
},
Sanctions: { id: "id", habboId: "habboId" },
UsersSettings: {
userId: "userId",
canTrade: "canTrade",
tradelockAmount: "tradelockAmount",
},
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: {
giveCredits: vi.fn(),
giveDuckets: vi.fn(),
givePointsGotw: vi.fn(),
giveBadge: vi.fn(),
},
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {},
User: {},
UsersCurrency: {},
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
@@ -98,83 +35,62 @@ const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
insertValues.mockImplementation(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
execute.mockImplementation(async (context, operation, input) => ({
ok: true,
data: {
before: { input },
after: {
completed: operation === "users.bulk-unban" ? 3 : 2,
total: Array.isArray(input.userIds) ? input.userIds.length : 1,
failedIds: [],
},
output: operation === "user.trade-lock" ? input : undefined,
},
correlationId: context.correlationId,
}));
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([]);
});
describe("bulkUnban", () => {
it("unbans users", async () => {
const r = await bulkUnban({ userIds: [1, 2, 3] });
expect(r.ok).toBe(true);
expect(r.data).toEqual({ unbanned: 3, total: 3 });
});
});
describe("bulkBan", () => {
it("bans users", async () => {
const r = await bulkBan({
userIds: [1, 2],
reason: "Spam",
duration: 3600,
describe("legacy bulk user wrappers", () => {
it("preserves result shapes while delegating the exact operations", async () => {
await expect(bulkUnban({ userIds: [1, 2, 3] })).resolves.toEqual({
ok: true,
data: { unbanned: 3, total: 3 },
});
expect(r.ok).toBe(true);
expect(r.data.banned).toBe(2);
expect(insertValues).toHaveBeenCalledTimes(2);
});
});
describe("bulkGiveCurrency", () => {
it("gives credits", async () => {
const r = await bulkGiveCurrency({
userIds: [1],
amount: 100,
type: "credits",
await expect(
bulkBan({ userIds: [1, 2], reason: "Spam", duration: 3600 }),
).resolves.toEqual({ ok: true, data: { banned: 2 } });
await expect(
bulkGiveCurrency({ userIds: [1], amount: 100, type: "credits" }),
).resolves.toEqual({
ok: true,
data: { given: 2, total: 1, failedIds: [] },
});
expect(r.data.given).toBe(1);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
expect(updateWhere).toHaveBeenCalled();
});
it("gives pixels", async () => {
const r = await bulkGiveCurrency({
userIds: [2],
amount: 50,
type: "pixels",
await expect(
bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }),
).resolves.toEqual({
ok: true,
data: { given: 2, total: 1, failedIds: [] },
});
expect(r.data.given).toBe(1);
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
it("gives points", async () => {
const r = await bulkGiveCurrency({
userIds: [3],
amount: 25,
type: "points",
});
expect(r.data.given).toBe(1);
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
expect(execute.mock.calls.map((call) => call[1])).toEqual([
"users.bulk-unban",
"users.bulk-ban",
"users.bulk-currency",
"users.bulk-badge",
]);
});
});
describe("bulkGiveBadge", () => {
it("gives badge to user", async () => {
selectLimit.mockResolvedValueOnce([]);
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
expect(r.data.given).toBe(1);
expect(insertValues).toHaveBeenCalled();
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
it("preserves the trade-lock API and exact normalized payload", async () => {
await expect(
setTradeLock({ userId: 9, untilUnix: 1234.8 }),
).resolves.toEqual({
ok: true,
data: { userId: 9, untilUnix: 1234 },
});
expect(execute).toHaveBeenLastCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
"user.trade-lock",
{ userId: 9, untilUnix: 1234 },
);
});
});
+114 -225
View File
@@ -1,40 +1,75 @@
"use server";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { and, eq } from "drizzle-orm";
import {
Ban,
db,
Sanctions,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
createLegacyPeopleMutationContext,
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 { rcon } from "@/lib/services/rcon";
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(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
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"),
},
]
: [],
)
: [];
}
export async function bulkUnban({
userIds,
}: {
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
await logStaffActivity({
staffId: staff.id,
action: "bulk_unban",
description: `Unbanned ${unbanned} user(s)`,
targetType: "user",
});
const result = await executeLegacy(staff, "users.bulk-unban", { userIds });
if (!result.ok) return { ok: false, error: "Bulk unban failed" };
return {
ok: true as const,
data: { unbanned, total: userIds.length },
ok: true,
data: {
unbanned: numberValue(result.data.after?.completed),
total: numberValue(result.data.after?.total),
},
};
}
@@ -48,34 +83,16 @@ export async function bulkBan({
duration: number;
}): Promise<ActionResult<{ banned: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const now = Math.floor(Date.now() / 1000);
let banned = 0;
for (const userId of userIds) {
try {
await db.insert(Ban).values({
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",
const result = await executeLegacy(staff, "users.bulk-ban", {
userIds,
reason,
duration,
});
return { ok: true as const, data: { banned } };
if (!result.ok) return { ok: false, error: "Bulk ban failed" };
return {
ok: true,
data: { banned: numberValue(result.data.after?.completed) },
};
}
export async function bulkGiveCurrency({
@@ -94,49 +111,19 @@ export async function bulkGiveCurrency({
}>
> {
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 db
.update(User)
.set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId));
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${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",
const result = await executeLegacy(staff, "users.bulk-currency", {
userIds,
amount,
type,
});
if (!result.ok) return { ok: false, error: "Bulk currency failed" };
return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
ok: true,
data: {
given: numberValue(result.data.after?.completed),
total: numberValue(result.data.after?.total),
failedIds: failedIds(result.data.after?.failedIds),
},
};
}
@@ -154,45 +141,18 @@ export async function bulkGiveBadge({
}>
> {
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 db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ 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",
const result = await executeLegacy(staff, "users.bulk-badge", {
userIds,
badgeCode,
});
if (!result.ok) return { ok: false, error: "Bulk badge failed" };
return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
ok: true,
data: {
given: numberValue(result.data.after?.completed),
total: numberValue(result.data.after?.total),
failedIds: failedIds(result.data.after?.failedIds),
},
};
}
@@ -202,7 +162,6 @@ export async function bulkAdjustCurrency({
type,
}: {
userIds: number[];
/** Positive = give, negative = take. Balances clamped at 0. */
amount: number;
type: "credits" | "pixels" | "points";
}): Promise<
@@ -214,29 +173,28 @@ export async function bulkAdjustCurrency({
> {
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" };
return { ok: false, 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" };
}
const result = await executeLegacy(staff, "users.bulk-currency", {
userIds,
amount,
type,
});
if (!result.ok) return { ok: false, error: "Currency adjustment failed" };
return {
ok: true as const,
ok: true,
data: {
adjusted: given.data.given,
total: given.data.total,
failedIds: given.data.failedIds,
adjusted: numberValue(result.data.after?.completed),
total: numberValue(result.data.after?.total),
failedIds: failedIds(result.data.after?.failedIds),
},
};
}
const take = Math.abs(Math.trunc(amount));
let adjusted = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
const failures: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
if (type === "credits") {
@@ -246,11 +204,13 @@ export async function bulkAdjustCurrency({
.where(eq(User.id, userId))
.limit(1);
if (!user) {
failedIds.push({ userId, reason: "Not found" });
failures.push({ userId, reason: "Not found" });
continue;
}
const next = Math.max(0, user.credits - take);
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
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
@@ -263,19 +223,17 @@ export async function bulkAdjustCurrency({
),
)
.limit(1);
const current = row?.amount ?? 0;
const next = Math.max(0, current - take);
const next = Math.max(0, (row?.amount ?? 0) - take);
await db
.insert(UsersCurrency)
.values({ userId, type: currencyType, amount: next })
.onDuplicateKeyUpdate({ set: { amount: next } });
}
adjusted++;
adjusted += 1;
} catch {
failedIds.push({ userId, reason: "Database error" });
failures.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_adjust_currency",
@@ -283,93 +241,24 @@ export async function bulkAdjustCurrency({
targetType: "user",
});
return {
ok: true as const,
data: { adjusted, total: userIds.length, failedIds },
ok: true,
data: { adjusted, total: userIds.length, failedIds: failures },
};
}
/**
* 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<ActionResult<{ userId: number; untilUnix: number }>> {
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(
const result = await executeLegacy(staff, "user.trade-lock", {
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,
untilUnix: until,
});
return { ok: true as const, data: { userId, untilUnix: until } };
if (!result.ok) return { ok: false, error: "Trade lock update failed" };
return { ok: true, data: { userId, untilUnix: until } };
}
+138
View File
@@ -0,0 +1,138 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: {
USERS_EDIT: "admin.users.edit",
USERS_BAN: "admin.users.ban",
USERS_RESET_PASSWORD: "admin.users.reset_password",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action", () => ({
adminAction:
(_options: unknown, handler: (context: unknown) => unknown) =>
(data: unknown) =>
handler({ data, session: { user: staff } }),
}));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {
constructor(message: string) {
super(message);
this.name = "ActionError";
}
},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {},
User: {},
UsersBadges: {},
UsersCurrency: {},
UsersSettings: {},
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
vi.mock("@/lib/services/rcon", () => ({ rcon: {} }));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { dismissApplication } from "./admin-applications";
import { addWord, deleteWord } from "./admin-wordfilter";
import { banUser, resetPassword, updateUser } from "./users";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff);
execute.mockImplementation(async (context, operation) => ({
ok: true,
data: {
before: {},
after: operation === "word-filter.update" ? { id: 12 } : {},
output:
operation === "user.reset-password"
? { newPassword: "temporary-password" }
: undefined,
},
correlationId: context.correlationId,
}));
});
describe("legacy user safe-action wrappers", () => {
it("preserves exact ACL-specific service delegation", async () => {
await (updateUser as never as (input: unknown) => Promise<unknown>)({
id: 7,
motto: "Ready",
});
await (banUser as never as (input: unknown) => Promise<unknown>)({
userId: 7,
reason: "abuse",
duration: 0,
type: "account",
});
const reset = await (
resetPassword as never as (input: unknown) => Promise<{
ok: boolean;
data: { newPassword: string };
}>
)({ userId: 7 });
expect(
execute.mock.calls.map((call) => [call[0].permission, call[1]]),
).toEqual([
["admin.users.edit", "user.update"],
["admin.users.ban", "user.ban"],
["admin.users.reset_password", "user.reset-password"],
]);
expect(reset.data.newPassword).toBe("temporary-password");
});
});
describe("legacy application and word-filter wrappers", () => {
it("keeps tolerant application dismissal and /admin revalidation", async () => {
await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
"application.decide",
{ applicationId: 9, decision: "dismiss" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/applications");
});
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
await expect(addWord({ word: "spam" })).resolves.toEqual({
ok: true,
data: { id: "12" },
});
await expect(deleteWord({ id: "12" })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ action: "add", word: "spam" },
{ action: "delete", id: 12 },
]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: vi.fn(async () => staff),
requirePermissionRateLimited: vi.fn(async () => staff),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
SETTINGS_EDIT: "admin.settings.edit",
USERS_EDIT: "admin.users.edit",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { disbandGuild } from "./admin-guilds";
import { addWhitelist } from "./admin-ip";
import { createTeam, deleteTeam } from "./admin-teams";
import { deleteWord } from "./admin-wordfilter";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const failure = (code: string) => ({
ok: false as const,
error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" },
correlationId: "wrapper-failure",
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("legacy wrapper failure compatibility", () => {
it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps IP and team persistence failures throwing", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(
addWhitelist(form({ ipAddress: "192.0.2.1" })),
).rejects.toThrow();
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps already-gone team and word-filter deletes successful", async () => {
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteWord({ id: "42" })).resolves.toEqual({
ok: true,
data: {},
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
});
});
-25
View File
@@ -2,30 +2,6 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
describe("setTradeLock drizzle + RCON contract", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
it("writes sanctions + users_settings via Drizzle", () => {
expect(src).toContain("@/lib/db");
expect(src).toContain("UsersSettings");
expect(src).toContain("Sanctions");
expect(src).toContain("canTrade");
expect(src).toContain("tradeLockedUntil");
expect(src).toMatch(/export async function setTradeLock/);
const fn = src.slice(src.indexOf("export async function setTradeLock"));
expect(fn).toContain("db.");
});
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
expect(rconSrc).toContain("settradelock");
expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)");
expect(src).toContain("rcon.setTradeLock");
expect(src).toContain("rcon.alertUser");
expect(src).toContain("rcon.disconnectUser");
});
});
describe("admin-photos drizzle contract", () => {
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
@@ -33,7 +9,6 @@ describe("admin-photos drizzle contract", () => {
expect(src).toContain("@/lib/db");
expect(src).toContain("CameraWeb");
expect(src).toContain("tryRemoveLocalPhotoFile");
expect(src).toContain("@/lib/db");
expect(src).toContain('revalidatePath("/photos")');
});
});
+145 -353
View File
@@ -1,18 +1,15 @@
"use server";
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import {
Ban,
db,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
createLegacyPeopleMutationContext,
type PeopleMutationOperation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { hashPassword } from "@/lib/auth/password";
import { db, User, UsersBadges, UsersCurrency, UsersSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -31,22 +28,23 @@ const DEFAULT_LOOK =
function isDuplicateKey(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as { code?: string | number; errno?: number };
return e.code === "P2002" || e.code === "ER_DUP_ENTRY" || e.errno === 1062;
const error = err as { code?: string | number; errno?: number };
return (
error.code === "P2002" ||
error.code === "ER_DUP_ENTRY" ||
error.errno === 1062
);
}
function duplicateField(err: unknown): "username" | "mail" | null {
if (!isDuplicateKey(err)) return null;
const e = err as {
message?: string;
meta?: { target?: string[] };
};
const target = e.meta?.target ?? [];
const error = err as { message?: string; meta?: { target?: string[] } };
const target = error.meta?.target ?? [];
if (target.includes("username")) return "username";
if (target.includes("mail")) return "mail";
const msg = e.message ?? "";
if (msg.includes("username")) return "username";
if (msg.includes("mail")) return "mail";
const message = error.message ?? "";
if (message.includes("username")) return "username";
if (message.includes("mail")) return "mail";
return null;
}
@@ -54,14 +52,11 @@ export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const hashedPassword = await hashPassword(password);
const now = Math.floor(Date.now() / 1000);
try {
const user = await db.transaction(async (tx) => {
const [result] = await tx.insert(User).values({
@@ -78,44 +73,73 @@ export const createUser = adminAction(
ipCurrent: "0.0.0.0",
});
const id = Number(result.insertId);
await tx.insert(UsersSettings).values({ userId: id });
await tx.insert(UsersCurrency).values([
{ userId: id, type: 0, amount: 5000 },
{ userId: id, type: 5, amount: 5000 },
]);
return { id, username };
});
logAudit({
void logAudit({
userId: ctx.session.user.id,
action: "user_create",
target: "User",
targetId: user.id,
after: { username, mail, rank },
});
notify({
void notify({
action: "user_edit",
actor: ctx.session.user.username,
target: username,
targetId: user.id,
details: "Account created by admin",
});
return actionOk({ id: user.id, username: user.username });
} catch (err) {
const field = duplicateField(err);
return actionOk(user);
} catch (error) {
const field = duplicateField(error);
if (field === "username") throw new ActionError("Username already taken");
if (field === "mail") throw new ActionError("Email already registered");
if (isDuplicateKey(err))
if (isDuplicateKey(error))
throw new ActionError("Username or email already in use");
throw err;
throw error;
}
},
);
const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
"user.alert": "Failed to send alert. Is the emulator running?",
"user.disconnect": "Failed to disconnect. Is the emulator running?",
"user.mute": "Failed to mute. Is the emulator running?",
"user.unmute": "Failed to unmute. Is the emulator running?",
"user.send-currency": "Failed to send credits. Is the emulator running?",
};
async function executeLegacy(
ctx: { session: { user: { id: number; username: string; rank: number } } },
permission: string,
operation: PeopleMutationOperation,
input: unknown,
) {
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
ctx.session.user,
permission,
createCorrelationId(),
),
operation,
input,
);
if (!result.ok) {
if (result.error.code === "NOT_FOUND")
throw new ActionError("User not found");
if (result.error.code === "FORBIDDEN") {
throw new ActionError("Cannot modify user with equal or higher rank");
}
throw new ActionError(legacyMessages[operation] ?? "User action failed");
}
return result.data;
}
const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(),
});
@@ -123,149 +147,115 @@ const updateUserInput = updateUserSchema.extend({
export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id, diamonds, duckets, ...userData } = ctx.data;
const targetUser = await guardRank(id, ctx.session.user.rank);
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const patch = Object.fromEntries(
Object.entries(userData).filter(([, v]) => v !== undefined),
) as Partial<{
username: string;
mail: string;
rank: number;
motto: string;
credits: number;
pixels: number;
}>;
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
invalidateLoginCache(targetUser.username);
if (diamonds !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 5, amount: diamonds })
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
}
if (duckets !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 0, amount: duckets })
.onDuplicateKeyUpdate({ set: { amount: duckets } });
}
logAudit({
userId: ctx.session.user.id,
action: "user_edit",
target: "User",
targetId: id,
before: {
username: targetUser.username,
mail: targetUser.mail,
rank: targetUser.rank,
},
after: userData,
const { id: userId, ...fields } = ctx.data;
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", {
userId,
fields,
});
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
});
return actionOk();
},
);
const banInput = banUserSchema.extend({});
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banInput },
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank);
const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0;
await db.insert(Ban).values({
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || "account",
ip: ip || "",
machineId: "",
});
await rcon.disconnectUser(userId);
logAudit({
userId: ctx.session.user.id,
action: "ban",
target: "User",
targetId: userId,
after: { reason, type, duration },
});
notify({
action: "ban",
actor: ctx.session.user.username,
target: targetUser.username,
details: reason,
});
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data);
return actionOk();
},
);
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: unbanInput },
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
async (ctx) => {
const { userId } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank);
await db.delete(Ban).where(eq(Ban.userId, userId));
logAudit({
userId: ctx.session.user.id,
action: "unban",
target: "User",
targetId: userId,
});
notify({
action: "unban",
actor: ctx.session.user.username,
target: targetUser.username,
});
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data);
return actionOk();
},
);
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
async (ctx) => {
const snapshot = await executeLegacy(
ctx,
PERMS.USERS_RESET_PASSWORD,
"user.reset-password",
ctx.data,
);
return actionOk({
newPassword: String(snapshot.output?.newPassword ?? ""),
});
},
);
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data);
return actionOk();
},
);
const alertUserSchema = userIdSchema.extend({
message: z.string().min(1).max(500),
});
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data);
return actionOk();
},
);
const muteSchema = userIdSchema.extend({
duration: z.coerce.number().int().min(0).default(0),
});
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data);
return actionOk();
},
);
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data);
return actionOk();
},
);
const sendCreditsSchema = userIdSchema.extend({
amount: z.coerce.number().int().min(1).max(1_000_000),
});
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data);
return actionOk();
},
);
async function guardRank(targetUserId: number, sessionRank: number) {
const [target] = await db
.select({ username: User.username, rank: User.rank, mail: User.mail })
.from(User)
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
}
export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank);
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
@@ -277,28 +267,21 @@ export const giveBadge = adminAction(
)
.limit(1);
if (existing) throw new ActionError("Badge already assigned");
await db.insert(UsersBadges).values({ userId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
return actionOk();
},
);
// ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1),
});
export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank);
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
@@ -310,199 +293,8 @@ export const removeBadge = adminAction(
)
.limit(1);
if (!existing) throw new ActionError("Badge not found");
await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
await rcon.removeBadge(userId, badgeCode);
return actionOk();
},
);
// ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) {
const [target] = await db
.select({
username: User.username,
rank: User.rank,
mail: User.mail,
})
.from(User)
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
}
// ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const newPassword = crypto
.randomBytes(12)
.toString("base64url")
.slice(0, 16);
const hashed = await hashPassword(newPassword);
await db
.update(User)
.set({ password: hashed })
.where(eq(User.id, ctx.data.userId));
invalidateLoginCache(target.username);
logAudit({
userId: ctx.session.user.id,
action: "reset_password",
target: "User",
targetId: ctx.data.userId,
});
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: target.username,
details: "Password reset",
});
return actionOk({ newPassword });
},
);
// ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.disconnectUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_disconnect",
target: "User",
targetId: ctx.data.userId,
});
notify({
action: "disconnect",
actor: ctx.session.user.username,
target: target.username,
});
return actionOk();
},
);
// ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500),
});
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?");
return actionOk();
},
);
// ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({
userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0),
});
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success)
throw new ActionError("Failed to mute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
});
return actionOk();
},
);
// ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to unmute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_unmute",
target: "User",
targetId: ctx.data.userId,
});
return actionOk();
},
);
// ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000),
});
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success)
throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_send_credits",
target: "User",
targetId: ctx.data.userId,
after: { amount: ctx.data.amount },
});
return actionOk();
},
);