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
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import {
|
||||
COMMUNITY_COMMAND_IDS,
|
||||
COMMUNITY_COMMANDS,
|
||||
createCommunityCommands,
|
||||
} from "./community-commands";
|
||||
|
||||
const expectedCommunityCommandIds = [
|
||||
"people.guild.disband",
|
||||
"people.application.decide",
|
||||
"people.team.change",
|
||||
"people.ip.action",
|
||||
"people.vpn.configure",
|
||||
"people.word-filter.update",
|
||||
] as const;
|
||||
|
||||
const commands = COMMUNITY_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People community and staff commands", () => {
|
||||
it("declares the exact stable People-owned command IDs", () => {
|
||||
expect(COMMUNITY_COMMAND_IDS).toEqual(expectedCommunityCommandIds);
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
expectedCommunityCommandIds,
|
||||
);
|
||||
expect(commands.every((command) => command.owner === "people")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the exact legacy ACL boundary", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
commands.map((command) => [command.id, command.capability.slugs]),
|
||||
),
|
||||
).toEqual({
|
||||
"people.guild.disband": [PERMS.USERS_EDIT],
|
||||
"people.application.decide": [PERMS.USERS_EDIT],
|
||||
"people.team.change": [PERMS.USERS_EDIT],
|
||||
"people.ip.action": [PERMS.SETTINGS_EDIT],
|
||||
"people.vpn.configure": [PERMS.SETTINGS_EDIT],
|
||||
"people.word-filter.update": [PERMS.WORDFILTER_EDIT],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a nonblank reason for every destructive, global, or security mutation", () => {
|
||||
expect(commands.every((command) => command.requiresReason)).toBe(true);
|
||||
for (const command of commands) {
|
||||
expect(
|
||||
confirmHousekeepingCommand(command, "\t", "community-reason"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.guild.disband", { guildId: 0 }],
|
||||
["people.application.decide", { applicationId: 7, decision: "accept" }],
|
||||
["people.team.change", { action: "create", rankName: " " }],
|
||||
["people.ip.action", { action: "add-whitelist", ipAddress: " " }],
|
||||
[
|
||||
"people.vpn.configure",
|
||||
{ enabled: true, provider: "unknown", apiKey: "", blockMessage: "" },
|
||||
],
|
||||
["people.word-filter.update", { action: "add", word: " " }],
|
||||
] as const)(
|
||||
"rejects an invalid bounded payload for %s",
|
||||
(commandId, input) => {
|
||||
const command = commands.find((entry) => entry.id === commandId);
|
||||
if (!command) throw new Error(`command missing: ${commandId}`);
|
||||
expect(command.input.safeParse(input).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("delegates the canonical operation without a redirect", async () => {
|
||||
const execute = vi.fn(async (context, operation, input) => ({
|
||||
ok: true as const,
|
||||
data: { before: { id: 9 }, after: null, operation, input },
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
const created = createCommunityCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find(
|
||||
(entry) => entry.id === "people.guild.disband",
|
||||
);
|
||||
if (!command) throw new Error("command missing");
|
||||
const input = command.input.parse({ guildId: 9 });
|
||||
await command.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "guild-command",
|
||||
ipAddress: "198.51.100.8",
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ correlationId: "guild-command" }),
|
||||
"guild.disband",
|
||||
{ guildId: 9 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const COMMUNITY_COMMAND_IDS = [
|
||||
"people.guild.disband",
|
||||
"people.application.decide",
|
||||
"people.team.change",
|
||||
"people.ip.action",
|
||||
"people.vpn.configure",
|
||||
"people.word-filter.update",
|
||||
] as const;
|
||||
|
||||
export type CommunityCommandId = (typeof COMMUNITY_COMMAND_IDS)[number];
|
||||
|
||||
interface CommandOptions<I> {
|
||||
readonly id: CommunityCommandId;
|
||||
readonly operation: PeopleMutationOperation;
|
||||
readonly capability: string;
|
||||
readonly input: z.ZodType<I>;
|
||||
}
|
||||
|
||||
function communityCommand<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: CommandOptions<I>,
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(options.capability),
|
||||
input: options.input,
|
||||
requiresReason: true,
|
||||
rateLimit: { attempts: 5, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const positiveId = z.number().int().positive();
|
||||
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
|
||||
export function createCommunityCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
return [
|
||||
communityCommand(service, {
|
||||
id: "people.guild.disband",
|
||||
operation: "guild.disband",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ guildId: positiveId }),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.application.decide",
|
||||
operation: "application.decide",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
applicationId: positiveId,
|
||||
decision: z.literal("dismiss"),
|
||||
}),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.team.change",
|
||||
operation: "team.change",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("create"),
|
||||
rankName: requiredText(255),
|
||||
badge: z.string().max(255).optional(),
|
||||
jobDescription: z.string().max(255).optional(),
|
||||
staffColor: z.string().min(1).max(255).optional(),
|
||||
hiddenRank: z.boolean().optional(),
|
||||
}),
|
||||
z.object({ action: z.literal("delete"), teamId: positiveId }),
|
||||
]),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.ip.action",
|
||||
operation: "ip.action",
|
||||
capability: PERMS.SETTINGS_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.enum(["add-whitelist", "add-blacklist"]),
|
||||
ipAddress: requiredText(255),
|
||||
asn: z.string().max(255).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["delete-whitelist", "delete-blacklist"]),
|
||||
id: positiveId,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.vpn.configure",
|
||||
operation: "vpn.configure",
|
||||
capability: PERMS.SETTINGS_EDIT,
|
||||
input: z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.enum(["none", "proxycheck", "ipqualityscore"]),
|
||||
apiKey: z.string().max(255),
|
||||
blockMessage: z.string().max(255),
|
||||
}),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.word-filter.update",
|
||||
operation: "word-filter.update",
|
||||
capability: PERMS.WORDFILTER_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("add"), word: requiredText(255) }),
|
||||
z.object({ action: z.literal("delete"), id: positiveId }),
|
||||
]),
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const COMMUNITY_COMMANDS = createCommunityCommands(
|
||||
peopleMutationService,
|
||||
);
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import {
|
||||
createPeopleMutationService,
|
||||
type PeopleMutationAdapter,
|
||||
} from "../services/mutations";
|
||||
import {
|
||||
createUserCommands,
|
||||
USER_COMMAND_IDS,
|
||||
USER_COMMANDS,
|
||||
} from "./user-commands";
|
||||
|
||||
const expectedUserCommandIds = [
|
||||
"people.user.update",
|
||||
"people.user.ban",
|
||||
"people.user.unban",
|
||||
"people.user.alert",
|
||||
"people.user.disconnect",
|
||||
"people.user.mute",
|
||||
"people.user.unmute",
|
||||
"people.user.reset-password",
|
||||
"people.user.send-currency",
|
||||
"people.user.trade-lock",
|
||||
"people.users.bulk-ban",
|
||||
"people.users.bulk-unban",
|
||||
"people.users.bulk-currency",
|
||||
"people.users.bulk-badge",
|
||||
] as const;
|
||||
|
||||
const commands = USER_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People user commands", () => {
|
||||
it("declares the exact deterministic user command list", () => {
|
||||
expect(USER_COMMAND_IDS).toEqual(expectedUserCommandIds);
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
expectedUserCommandIds,
|
||||
);
|
||||
expect(commands.every((command) => command.owner === "people")).toBe(true);
|
||||
expect(commands.every((command) => command.risk === "sensitive")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the exact legacy ACL for every user operation", () => {
|
||||
const capabilities = Object.fromEntries(
|
||||
commands.map((command) => [command.id, command.capability.slugs]),
|
||||
);
|
||||
expect(capabilities).toEqual({
|
||||
"people.user.update": [PERMS.USERS_EDIT],
|
||||
"people.user.ban": [PERMS.USERS_BAN],
|
||||
"people.user.unban": [PERMS.USERS_BAN],
|
||||
"people.user.alert": [PERMS.USERS_EDIT],
|
||||
"people.user.disconnect": [PERMS.USERS_EDIT],
|
||||
"people.user.mute": [PERMS.USERS_EDIT],
|
||||
"people.user.unmute": [PERMS.USERS_EDIT],
|
||||
"people.user.reset-password": [PERMS.USERS_RESET_PASSWORD],
|
||||
"people.user.send-currency": [PERMS.USERS_EDIT],
|
||||
"people.user.trade-lock": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-ban": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-unban": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-currency": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-badge": [PERMS.USERS_EDIT],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires reasons for sanctions, security changes, currency, and bulk mutations", () => {
|
||||
const withoutReason = commands
|
||||
.filter((command) => !command.requiresReason)
|
||||
.map((command) => command.id);
|
||||
expect(withoutReason).toEqual(["people.user.update", "people.user.alert"]);
|
||||
|
||||
for (const command of commands.filter((entry) => entry.requiresReason)) {
|
||||
expect(
|
||||
confirmHousekeepingCommand(command, " ", "people-reason"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.user.update", { userId: 7, fields: {} }],
|
||||
["people.user.alert", { userId: 7, message: " " }],
|
||||
["people.user.mute", { userId: 7, duration: 87_601 }],
|
||||
["people.user.send-currency", { userId: 7, amount: 1_000_001 }],
|
||||
["people.users.bulk-ban", { userIds: [], reason: "reason", duration: 0 }],
|
||||
[
|
||||
"people.users.bulk-badge",
|
||||
{
|
||||
userIds: Array.from({ length: 101 }, (_, index) => index + 1),
|
||||
badgeCode: "ADM",
|
||||
},
|
||||
],
|
||||
] as const)(
|
||||
"rejects an invalid bounded payload for %s",
|
||||
(commandId, input) => {
|
||||
const command = commands.find((entry) => entry.id === commandId);
|
||||
if (!command) throw new Error(`command missing: ${commandId}`);
|
||||
expect(command.input.safeParse(input).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("delegates parsed input and correlation to the redirect-free service", async () => {
|
||||
const execute = vi.fn(async (context, operation, input) => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: { operation, input } },
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
const created = createUserCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find(
|
||||
(entry) => entry.id === "people.user.send-currency",
|
||||
);
|
||||
if (!command) throw new Error("command missing");
|
||||
const input = command.input.parse({ userId: 7, amount: 25 });
|
||||
const result = await command.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "people-command",
|
||||
ipAddress: "198.51.100.8",
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ correlationId: "people-command" }),
|
||||
"user.send-currency",
|
||||
{ userId: 7, amount: 25 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: null, after: { operation: "user.send-currency" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("People mutation service boundary", () => {
|
||||
it("fails closed before the adapter when the exact capability is absent", async () => {
|
||||
const execute = vi.fn(async () => ({ before: null, after: null }));
|
||||
const service = createPeopleMutationService({ execute });
|
||||
const result = await service.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "denied-people",
|
||||
},
|
||||
"user.ban",
|
||||
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
correlationId: "denied-people",
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns adapter before and after snapshots and sanitizes failures", async () => {
|
||||
const snapshotAdapter: PeopleMutationAdapter = {
|
||||
execute: async () => ({
|
||||
before: { rank: 2 },
|
||||
after: { rank: 3 },
|
||||
}),
|
||||
};
|
||||
const success = await createPeopleMutationService(snapshotAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "snapshot-people",
|
||||
},
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 3 } },
|
||||
);
|
||||
expect(success).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { rank: 2 }, after: { rank: 3 } },
|
||||
});
|
||||
|
||||
const failureAdapter: PeopleMutationAdapter = {
|
||||
execute: async () => {
|
||||
throw new Error("database password=secret");
|
||||
},
|
||||
};
|
||||
const failure = await createPeopleMutationService(failureAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "failed-people",
|
||||
},
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Ready" } },
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain("password=secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const USER_COMMAND_IDS = [
|
||||
"people.user.update",
|
||||
"people.user.ban",
|
||||
"people.user.unban",
|
||||
"people.user.alert",
|
||||
"people.user.disconnect",
|
||||
"people.user.mute",
|
||||
"people.user.unmute",
|
||||
"people.user.reset-password",
|
||||
"people.user.send-currency",
|
||||
"people.user.trade-lock",
|
||||
"people.users.bulk-ban",
|
||||
"people.users.bulk-unban",
|
||||
"people.users.bulk-currency",
|
||||
"people.users.bulk-badge",
|
||||
] as const;
|
||||
|
||||
export type UserCommandId = (typeof USER_COMMAND_IDS)[number];
|
||||
|
||||
interface UserCommandOptions<I> {
|
||||
readonly id: UserCommandId;
|
||||
readonly operation: PeopleMutationOperation;
|
||||
readonly capability: string;
|
||||
readonly input: z.ZodType<I>;
|
||||
readonly requiresReason: boolean;
|
||||
readonly attempts?: number;
|
||||
}
|
||||
|
||||
function userCommand<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: UserCommandOptions<I>,
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(options.capability),
|
||||
input: options.input,
|
||||
requiresReason: options.requiresReason,
|
||||
rateLimit: { attempts: options.attempts ?? 10, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const positiveId = z.number().int().positive();
|
||||
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
const userIds = z.array(positiveId).min(1).max(100);
|
||||
const optionalUpdateFields = {
|
||||
username: z.string().min(3).max(20).optional(),
|
||||
mail: z.email().optional(),
|
||||
rank: z.number().int().min(1).max(7).optional(),
|
||||
motto: z.string().max(127).optional(),
|
||||
credits: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
pixels: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
diamonds: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
duckets: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
};
|
||||
const updateFields = z.union([
|
||||
z.object({ ...optionalUpdateFields, username: z.string().min(3).max(20) }),
|
||||
z.object({ ...optionalUpdateFields, mail: z.email() }),
|
||||
z.object({ ...optionalUpdateFields, rank: z.number().int().min(1).max(7) }),
|
||||
z.object({ ...optionalUpdateFields, motto: z.string().max(127) }),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
credits: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
pixels: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
diamonds: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
duckets: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
]);
|
||||
|
||||
export function createUserCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
return [
|
||||
userCommand(service, {
|
||||
id: "people.user.update",
|
||||
operation: "user.update",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId, fields: updateFields }),
|
||||
requiresReason: false,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.ban",
|
||||
operation: "user.ban",
|
||||
capability: PERMS.USERS_BAN,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
reason: requiredText(500),
|
||||
duration: z.number().int().min(0).max(87_600),
|
||||
type: z.enum(["account", "ip", "machine"]),
|
||||
ip: z.string().max(255).optional(),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.unban",
|
||||
operation: "user.unban",
|
||||
capability: PERMS.USERS_BAN,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.alert",
|
||||
operation: "user.alert",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId, message: requiredText(500) }),
|
||||
requiresReason: false,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.disconnect",
|
||||
operation: "user.disconnect",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.mute",
|
||||
operation: "user.mute",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
duration: z.number().int().min(0).max(87_600),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.unmute",
|
||||
operation: "user.unmute",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.reset-password",
|
||||
operation: "user.reset-password",
|
||||
capability: PERMS.USERS_RESET_PASSWORD,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.send-currency",
|
||||
operation: "user.send-currency",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
amount: z.number().int().min(1).max(1_000_000),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.trade-lock",
|
||||
operation: "user.trade-lock",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
untilUnix: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-ban",
|
||||
operation: "users.bulk-ban",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userIds,
|
||||
reason: requiredText(500),
|
||||
duration: z.number().int().min(0).max(315_360_000),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-unban",
|
||||
operation: "users.bulk-unban",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userIds }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-currency",
|
||||
operation: "users.bulk-currency",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userIds,
|
||||
amount: z.number().int().min(1).max(1_000_000),
|
||||
type: z.enum(["credits", "pixels", "points"]),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-badge",
|
||||
operation: "users.bulk-badge",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userIds, badgeCode: requiredText(20) }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const USER_COMMANDS = createUserCommands(peopleMutationService);
|
||||
Reference in new issue
Block a user