"use server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { createPeopleMutationInvocation, 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"; import { logAudit } from "@/lib/services/audit"; import { rcon } from "@/lib/services/rcon"; import { notify } from "@/lib/services/webhook"; import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema, } from "@/lib/validators/user"; const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64"; function isDuplicateKey(err: unknown): boolean { if (!err || typeof err !== "object") return false; 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 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 message = error.message ?? ""; if (message.includes("username")) return "username"; if (message.includes("mail")) return "mail"; return null; } 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({ username, mail, password: hashedPassword, rank, motto: motto || "I'm new here!", look: DEFAULT_LOOK, credits: 5000, pixels: 5000, accountCreated: now, ipRegister: "0.0.0.0", 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 }; }); void logAudit({ userId: ctx.session.user.id, action: "user_create", target: "User", targetId: user.id, after: { username, mail, rank }, }); void notify({ action: "user_edit", actor: ctx.session.user.username, target: username, targetId: user.id, details: "Account created by admin", }); 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(error)) throw new ActionError("Username or email already in use"); throw error; } }, ); const legacyMessages: Partial> = { "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 } } }, operation: PeopleMutationOperation, input: unknown, ) { const result = await peopleMutationService.execute( createPeopleMutationInvocation(ctx.session.user, 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(), }); export const updateUser = adminAction( { permission: PERMS.USERS_EDIT, schema: updateUserInput }, async (ctx) => { const { id: userId, ...fields } = ctx.data; await executeLegacy(ctx, "user.update", { userId, fields, }); return actionOk(); }, ); export const banUser = adminAction( { permission: PERMS.USERS_BAN, schema: banUserSchema }, async (ctx) => { await executeLegacy(ctx, "user.ban", ctx.data); return actionOk(); }, ); const userIdSchema = z.object({ userId: z.coerce.number().int().positive() }); export const unbanUser = adminAction( { permission: PERMS.USERS_BAN, schema: userIdSchema }, async (ctx) => { await executeLegacy(ctx, "user.unban", ctx.data); return actionOk(); }, ); export const resetPassword = adminAction( { permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema }, async (ctx) => { const snapshot = await executeLegacy(ctx, "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, "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, "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, "user.mute", ctx.data); return actionOk(); }, ); export const unmuteUser = adminAction( { permission: PERMS.USERS_EDIT, schema: userIdSchema }, async (ctx) => { await executeLegacy(ctx, "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, "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) .where( and( eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, badgeCode), ), ) .limit(1); if (existing) throw new ActionError("Badge already assigned"); await db.insert(UsersBadges).values({ userId, badgeCode }); await rcon.giveBadge(userId, badgeCode); return actionOk(); }, ); 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) .where( and( eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, badgeCode), ), ) .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(); }, );