feat(housekeeping): deliver system vertical

This commit is contained in:
Simo committed 2026-08-28 23:31:47 +02:00
1 parent 0117b45d74
commit 3788ecd9f1
33 files changed
+4548 -423

No files matched your search

+114 -106
View File
@@ -1,48 +1,96 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import {
type SystemMutationContext,
type SystemMutationOperation,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import type { AdminActionContext } from "@/lib/foundation/types";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
function mutationContext(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
): SystemMutationContext {
return {
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
rank: Number(ctx.session.user.rank),
username: ctx.session.user.username,
},
ctx.permissions,
),
correlationId: String(ctx.requestId),
};
}
async function runRconMutation(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
operation: SystemMutationOperation,
input: unknown,
): Promise<void> {
const result = await systemMutationService.execute(
mutationContext(ctx),
operation,
input,
);
if (result.ok) return;
if (result.error.messageKey === "errors.housekeeping.system.userNotFound") {
throw new ActionError("User not found");
}
if (result.error.messageKey === "errors.housekeeping.system.rankNotFound") {
throw new ActionError("Rank does not exist");
}
if (
result.error.messageKey ===
"errors.housekeeping.system.cannotChangePeerRank"
) {
throw new ActionError("Cannot change rank of a user at or above your rank");
}
if (
result.error.messageKey ===
"errors.housekeeping.system.cannotAssignPeerRank"
) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
throw new ActionError(RCON_FAIL);
}
function revalidate(): void {
revalidatePath(PATH);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateCatalog());
revalidatePath(PATH);
async (ctx) => {
await runRconMutation(ctx, "rcon.update-catalog", {});
revalidate();
return actionOk();
},
);
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateWordFilter());
revalidatePath(PATH);
async (ctx) => {
await runRconMutation(ctx, "rcon.update-word-filter", {});
revalidate();
return actionOk();
},
);
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.send("updatenavigator", null));
revalidatePath(PATH);
async (ctx) => {
await runRconMutation(ctx, "rcon.update-navigator", {});
revalidate();
return actionOk();
},
);
@@ -51,13 +99,13 @@ const hotelAlertSchema = z.object({
message: z.string().trim().min(1).max(512),
});
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.send("hotelalert", { message }));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.hotel-alert", {
message: ctx.data.message.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -67,13 +115,14 @@ const disconnectSchema = z.object({
username: z.string().trim().min(1),
});
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.disconnect-user", {
userId: ctx.data.userId,
username: ctx.data.username.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -83,13 +132,14 @@ const alertUserSchema = z.object({
message: z.string().trim().min(1).max(512),
});
/** Send an alert to a specific user (rcon: alertuser). */
export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.alert-user", {
userId: ctx.data.userId,
message: ctx.data.message.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -99,14 +149,11 @@ const forwardUserSchema = z.object({
roomId: z.coerce.number().int().positive(),
});
/** Forward a user to a specific room (rcon: forwarduser). */
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await requireRconOk(
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
);
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.forward-user", ctx.data);
revalidate();
return actionOk();
},
);
@@ -116,14 +163,14 @@ const giveCreditsSchema = z.object({
credits: z.coerce.number().int().positive(),
});
/** Give credits to a user (rcon: givecredits). */
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.give-credits", {
userId: ctx.data.userId,
amount: ctx.data.credits,
});
revalidate();
return actionOk();
},
);
@@ -133,26 +180,20 @@ const giveAmountSchema = z.object({
amount: z.coerce.number().int().positive(),
});
/** Give duckets to a user (rcon: givepoints type=duckets). */
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.give-duckets", ctx.data);
revalidate();
return actionOk();
},
);
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.give-diamonds", ctx.data);
revalidate();
return actionOk();
},
);
@@ -162,13 +203,14 @@ const giveBadgeSchema = z.object({
badge: z.string().trim().min(1).max(32),
});
/** Give a badge to a user (rcon: givebadge). */
export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
const badge = ctx.data.badge.normalize("NFC");
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.give-badge", {
userId: ctx.data.userId,
badge: ctx.data.badge.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -178,13 +220,14 @@ const setMottoSchema = z.object({
motto: z.string().trim().min(1).max(127),
});
/** Set a user's motto (rcon: setmotto). */
export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
const motto = ctx.data.motto.normalize("NFC");
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.set-motto", {
userId: ctx.data.userId,
motto: ctx.data.motto.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -194,47 +237,11 @@ const setRankSchema = z.object({
rank: z.coerce.number().int().min(1).max(9999),
});
/** Set a user's rank (rcon: setrank). */
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, ctx.data.userId))
.limit(1);
if (!target) throw new ActionError("User not found");
let rankExists: { id: number }[] = [];
try {
const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
);
rankExists = rows as unknown as { id: number }[];
} catch {
rankExists = [];
}
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
if (target.rank >= staffRank) {
throw new ActionError(
"Cannot change rank of a user at or above your rank",
);
}
if (ctx.data.rank >= staffRank) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await db
.update(User)
.set({ rank: ctx.data.rank })
.where(eq(User.id, ctx.data.userId));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.set-rank", ctx.data);
revalidate();
return actionOk();
},
);
@@ -244,13 +251,14 @@ const executeCommandSchema = z.object({
command: z.string().trim().min(1).max(100),
});
/** Execute a command as a user (rcon: executecommand). */
export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.execute-command", {
userId: ctx.data.userId,
command: ctx.data.command.normalize("NFC"),
});
revalidate();
return actionOk();
},
);
@@ -261,15 +269,15 @@ const sendGiftSchema = z.object({
message: z.string().trim().max(255).optional().default("Here is a gift."),
});
/** Send a gift to a user (rcon: sendgift). */
export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
await requireRconOk(
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
);
revalidatePath(PATH);
await runRconMutation(ctx, "rcon.send-gift", {
userId: ctx.data.userId,
itemId: ctx.data.itemId,
message: ctx.data.message.trim().slice(0, 255) || "Here is a gift.",
});
revalidate();
return actionOk();
},
);