feat(housekeeping): deliver people account workflows
This commit is contained in:
1 parent
7920d4f46c
commit
e1b31ff773
45 files changed
+4290
-1243
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 } };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user