import { useCallback } from 'react'; import { GetRoomSession, HousekeepingApi, HousekeepingErrorKey, IHousekeepingActionResult, LocalizeText, NotificationBubbleType, validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason } from '../../api'; import { useNotification } from '../notification'; import { useHousekeepingStore } from './useHousekeepingStore'; const SUCCESS_KEY = 'housekeeping.action.success'; const ERROR_KEY = 'housekeeping.action.error'; type ToastFn = (message: string, type: string, imageUrl?: string, internalLink?: string, senderName?: string) => void; const localizeOrPassthrough = (key: string): string => { if(!key) return ''; if(!key.includes('.')) return key; const localized = LocalizeText(key); return (localized === key) ? key : localized; }; const wrap = async ( runner: () => Promise, markPending: () => void, markDone: (errorKey: string | null, successKey: string | null) => void, toast: ToastFn, recordMetric: (action: string, latencyMs: number, isError: boolean) => void, actionLabel: string ): Promise => { markPending(); const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); const measure = (isError: boolean) => { const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); recordMetric(actionLabel, endedAt - startedAt, isError); }; try { const result = await runner(); if(result && result.ok === false) { // Error path: status banner only — the banner is inline // and stays put until dismissed, more visible than a // transient bubble for a failure that needs operator // attention. markDone(result.message || ERROR_KEY, null); measure(true); return result; } const successKey = result?.message || SUCCESS_KEY; markDone(null, successKey); // Success path also fires a transient toast so the operator // gets feedback without scanning the banner — banner stays // as a fallback for users that have bubbles disabled. toast(localizeOrPassthrough(successKey), NotificationBubbleType.INFO); measure(false); return result; } catch(error) { markDone(String((error as Error)?.message ?? error), null); measure(true); return null; } }; const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s: string | null) => void): boolean => { if(key === HousekeepingErrorKey.NONE) return true; markDone(`housekeeping.validation.${ key }`, null); return false; }; /** * Imperative facade for every HK admin action. State (selected * user/room, status banner) lives in `useHousekeepingStore`; this * hook reads it for context (e.g. the currently-selected target) * and writes only the action-pending / status flags via * `markActionPending` / `markActionDone`. Keeping the read-only * state in a separate filter would still work, but the singleton * store keeps invocation simple for the panel views that already * pull state via `useHousekeepingStore`. */ export const useHousekeepingActions = () => { const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric } = useHousekeepingStore(); const { showSingleBubble } = useNotification(); // Stable closure-bound runner so every action below stays a // one-liner: only the runner thunk + a per-action telemetry // label change per call site. The label keys into the metrics // map; a missing label defaults to "anonymous" so untagged calls // still produce a metric row. const runAction = useCallback((runner: () => Promise, actionLabel: string = 'anonymous') => wrap(runner, markActionPending, markActionDone, showSingleBubble, recordActionMetric, actionLabel), [ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]); // -- USER -------------------------------------------------------- const banUser = useCallback(async (userId: number, reason: string, hours: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateReason(reason), markActionDone)) return null; if(!validationOr(validateBanHours(hours), markActionDone)) return null; return runAction(() => HousekeepingApi.banUser(userId, reason, hours), 'banUser'); }, [ runAction, markActionDone ]); const unbanUser = useCallback(async (userId: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; return runAction(() => HousekeepingApi.unbanUser(userId), 'unbanUser'); }, [ runAction, markActionDone ]); const muteUser = useCallback(async (userId: number, reason: string, minutes: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateReason(reason), markActionDone)) return null; if(!validationOr(validateBanHours(minutes), markActionDone)) return null; return runAction(() => HousekeepingApi.muteUser(userId, reason, minutes), 'muteUser'); }, [ runAction, markActionDone ]); const kickUser = useCallback(async (userId: number, reason: string) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateReason(reason), markActionDone)) return null; return runAction(() => HousekeepingApi.kickUser(userId, reason), 'kickUser'); }, [ runAction, markActionDone ]); const forceDisconnectUser = useCallback(async (userId: number, reason: string) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateReason(reason), markActionDone)) return null; return runAction(() => HousekeepingApi.forceDisconnectUser(userId, reason), 'forceDisconnectUser'); }, [ runAction, markActionDone ]); const resetUserPassword = useCallback(async (userId: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; return runAction(() => HousekeepingApi.resetUserPassword(userId), 'resetUserPassword'); }, [ runAction, markActionDone ]); const setUserRank = useCallback(async (userId: number, rank: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateRank(rank), markActionDone)) return null; const result = await runAction(() => HousekeepingApi.setUserRank(userId, rank), 'setUserRank'); if(result && result.ok !== false && selectedUser && selectedUser.id === userId) { setSelectedUser({ ...selectedUser, rank }); } return result; }, [ runAction, markActionDone, selectedUser, setSelectedUser ]); const tradeLockUser = useCallback(async (userId: number, hours: number, reason: string) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateReason(reason), markActionDone)) return null; if(!validationOr(validateBanHours(hours), markActionDone)) return null; return runAction(() => HousekeepingApi.tradeLockUser(userId, hours, reason), 'tradeLockUser'); }, [ runAction, markActionDone ]); // -- ROOM -------------------------------------------------------- const openRoom = useCallback(async (roomId: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; const result = await runAction(() => HousekeepingApi.openRoom(roomId), 'openRoom'); if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId) { setSelectedRoom({ ...selectedRoom, isLocked: false }); } return result; }, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]); const closeRoom = useCallback(async (roomId: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; const result = await runAction(() => HousekeepingApi.closeRoom(roomId), 'closeRoom'); if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId) { setSelectedRoom({ ...selectedRoom, isLocked: true }); } return result; }, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]); const muteRoom = useCallback(async (roomId: number, minutes: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; if(!validationOr(validateBanHours(minutes), markActionDone)) return null; return runAction(() => HousekeepingApi.muteRoom(roomId, minutes), 'muteRoom'); }, [ runAction, markActionDone ]); const kickAllFromRoom = useCallback(async (roomId: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; return runAction(() => HousekeepingApi.kickAllFromRoom(roomId), 'kickAllFromRoom'); }, [ runAction, markActionDone ]); const transferRoomOwnership = useCallback(async (roomId: number, newOwnerId: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; if(!validationOr(validatePositiveId(newOwnerId, 'user'), markActionDone)) return null; return runAction(() => HousekeepingApi.transferRoomOwnership(roomId, newOwnerId), 'transferRoomOwnership'); }, [ runAction, markActionDone ]); const deleteRoom = useCallback(async (roomId: number) => { if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null; const result = await runAction(() => HousekeepingApi.deleteRoom(roomId), 'deleteRoom'); if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId) { setSelectedRoom(null); } return result; }, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]); // -- ECONOMY ----------------------------------------------------- const giveCredits = useCallback(async (userId: number, amount: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateAmount(amount), markActionDone)) return null; return runAction(() => HousekeepingApi.giveCredits(userId, amount), 'giveCredits'); }, [ runAction, markActionDone ]); const giveDuckets = useCallback(async (userId: number, amount: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateAmount(amount), markActionDone)) return null; return runAction(() => HousekeepingApi.giveDuckets(userId, amount), 'giveDuckets'); }, [ runAction, markActionDone ]); const giveDiamonds = useCallback(async (userId: number, amount: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateAmount(amount), markActionDone)) return null; return runAction(() => HousekeepingApi.giveDiamonds(userId, amount), 'giveDiamonds'); }, [ runAction, markActionDone ]); const grantItem = useCallback(async (userId: number, itemId: number, quantity: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validatePositiveId(itemId, 'user'), markActionDone)) return null; if(!validationOr(validateAmount(quantity), markActionDone)) return null; return runAction(() => HousekeepingApi.grantItem(userId, itemId, quantity), 'grantItem'); }, [ runAction, markActionDone ]); const setHcSubscription = useCallback(async (userId: number, days: number) => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; if(!validationOr(validateAmount(days), markActionDone)) return null; return runAction(() => HousekeepingApi.setHcSubscription(userId, days), 'setHcSubscription'); }, [ runAction, markActionDone ]); const sendHotelAlert = useCallback(async (message: string) => { if(!validationOr(validateReason(message), markActionDone)) return null; return runAction(() => HousekeepingApi.sendHotelAlert(message), 'sendHotelAlert'); }, [ runAction, markActionDone ]); // -- LIVE IN-ROOM ACTIONS --------------------------------------- // These bridge directly to the active RoomSession so the // sanction lands on the current game state (no server roundtrip // through the HTTP layer). Use for "the user is here, right // now" sanctions; persistent admin actions still go through the // HTTP API above. const kickFromCurrentRoom = useCallback((webUserId: number) => { const session = GetRoomSession(); if(!session) { markActionDone('housekeeping.live.no_room', null); return false; } try { session.sendKickMessage(webUserId); markActionDone(null, 'housekeeping.live.kicked'); showSingleBubble(localizeOrPassthrough('housekeeping.live.kicked'), NotificationBubbleType.INFO); return true; } catch(error) { markActionDone(String((error as Error)?.message ?? error), null); return false; } }, [ markActionDone, showSingleBubble ]); const banFromCurrentRoom = useCallback((webUserId: number, severity: 'hour' | 'day' | 'perm' = 'hour') => { const session = GetRoomSession(); if(!session) { markActionDone('housekeeping.live.no_room', null); return false; } const code = severity === 'perm' ? 'RWUAM_BAN_USER_PERM' : severity === 'day' ? 'RWUAM_BAN_USER_DAY' : 'RWUAM_BAN_USER_HOUR'; try { session.sendBanMessage(webUserId, code); markActionDone(null, 'housekeeping.live.banned'); showSingleBubble(localizeOrPassthrough('housekeeping.live.banned'), NotificationBubbleType.INFO); return true; } catch(error) { markActionDone(String((error as Error)?.message ?? error), null); return false; } }, [ markActionDone, showSingleBubble ]); // -- BULK HTTP ACTIONS ------------------------------------------ // Loop with Promise.allSettled so a single failure doesn't abort // the rest of the batch. Aggregated success/failure counts land // in the status banner; per-user errors fall through to the audit // log on the server side. const runBulk = useCallback(async ( userIds: ReadonlyArray, single: (id: number) => Promise, actionLabel: string ): Promise<{ ok: number; failed: number }> => { if(userIds.length === 0) return { ok: 0, failed: 0 }; markActionPending(); const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); const settled = await Promise.allSettled(userIds.map(id => single(id))); let ok = 0; let failed = 0; for(const outcome of settled) { if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++; else failed++; } // One metric sample per bulk run rather than per user — the // bulk timing is what the operator cares about. Bucket suffix // `:bulk` keeps the metric separate from the matching single // action in the telemetry panel. const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); recordActionMetric(`${ actionLabel }:bulk`, endedAt - startedAt, failed > 0); const summaryKey = failed === 0 ? 'housekeeping.bulk.success' : 'housekeeping.bulk.partial'; markActionDone(failed > 0 && ok === 0 ? 'housekeeping.bulk.failed' : null, failed === 0 ? summaryKey : null); showSingleBubble(`${ localizeOrPassthrough('housekeeping.bulk.done') } — ${ ok }/${ userIds.length }`, NotificationBubbleType.INFO); return { ok, failed }; }, [ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]); const banUsersBulk = useCallback((userIds: ReadonlyArray, reason: string, hours: number) => runBulk(userIds, id => HousekeepingApi.banUser(id, reason, hours), 'banUser'), [ runBulk ]); const kickUsersBulk = useCallback((userIds: ReadonlyArray, reason: string) => runBulk(userIds, id => HousekeepingApi.kickUser(id, reason), 'kickUser'), [ runBulk ]); const muteUsersBulk = useCallback((userIds: ReadonlyArray, reason: string, minutes: number) => runBulk(userIds, id => HousekeepingApi.muteUser(id, reason, minutes), 'muteUser'), [ runBulk ]); const muteInCurrentRoom = useCallback((webUserId: number, minutes: number) => { const session = GetRoomSession(); if(!session) { markActionDone('housekeeping.live.no_room', null); return false; } try { session.sendMuteMessage(webUserId, minutes); markActionDone(null, 'housekeeping.live.muted'); showSingleBubble(localizeOrPassthrough('housekeeping.live.muted'), NotificationBubbleType.INFO); return true; } catch(error) { markActionDone(String((error as Error)?.message ?? error), null); return false; } }, [ markActionDone, showSingleBubble ]); return { banUser, unbanUser, muteUser, kickUser, forceDisconnectUser, resetUserPassword, setUserRank, tradeLockUser, openRoom, closeRoom, muteRoom, kickAllFromRoom, transferRoomOwnership, deleteRoom, giveCredits, giveDuckets, giveDiamonds, grantItem, setHcSubscription, sendHotelAlert, kickFromCurrentRoom, banFromCurrentRoom, muteInCurrentRoom, banUsersBulk, kickUsersBulk, muteUsersBulk }; };