From 0142508eb59a16d465394fee7ba398e39d4ceda2 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 18 Mar 2026 13:04:32 +0100 Subject: [PATCH] fix: add type safety and error handling to event bus - Add DomainEventPayloads interface to events.ts for typed event payloads - Wrap dashboard listeners with fireAndForget() to prevent unhandled promise rejections - Type all listener parameters explicitly using DomainEventPayloads - Add idempotency guard to registerDomainEventListeners to prevent double registration on hot-reload Co-Authored-By: Claude Sonnet 4.6 --- shared/lib/eventWiring.ts | 86 ++++++++++++++++++++++++--------------- shared/lib/events.ts | 13 ++++++ 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/shared/lib/eventWiring.ts b/shared/lib/eventWiring.ts index c11fdc3..d637289 100644 --- a/shared/lib/eventWiring.ts +++ b/shared/lib/eventWiring.ts @@ -1,4 +1,5 @@ import { systemEvents, EVENTS } from "@shared/lib/events"; +import type { DomainEventPayloads } from "@shared/lib/events"; import { questService } from "@shared/modules/quest/quest.service"; import { dashboardService } from "@shared/modules/dashboard/dashboard.service"; @@ -9,64 +10,83 @@ import { dashboardService } from "@shared/modules/dashboard/dashboard.service"; * This wiring replaces dynamic imports that were previously used to avoid * circular dependencies between services (e.g., economy -> quest -> economy). */ + +function fireAndForget(fn: () => Promise) { + fn().catch(err => console.error("[EventWiring] Fire-and-forget handler failed:", err)); +} + +let registered = false; export function registerDomainEventListeners() { + if (registered) return; + registered = true; + // --- Quest progress tracking (awaited via emitAsync to preserve tx atomicity) --- - systemEvents.on(EVENTS.DOMAIN.BALANCE_CHANGED, async ({ userId, type, tx }) => { - await questService.handleEvent(userId, type, 1, tx); + systemEvents.on(EVENTS.DOMAIN.BALANCE_CHANGED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.BALANCE_CHANGED]) => { + await questService.handleEvent(payload.userId, payload.type, 1, payload.tx); }); - systemEvents.on(EVENTS.DOMAIN.XP_GAINED, async ({ userId, amount, tx }) => { - await questService.handleEvent(userId, 'XP_GAIN', amount, tx); + systemEvents.on(EVENTS.DOMAIN.XP_GAINED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.XP_GAINED]) => { + await questService.handleEvent(payload.userId, 'XP_GAIN', payload.amount, payload.tx); }); - systemEvents.on(EVENTS.DOMAIN.ITEM_COLLECTED, async ({ userId, itemId, quantity, tx }) => { - await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, quantity, tx); + systemEvents.on(EVENTS.DOMAIN.ITEM_COLLECTED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.ITEM_COLLECTED]) => { + await questService.handleEvent(payload.userId, `ITEM_COLLECT:${payload.itemId}`, payload.quantity, payload.tx); }); - systemEvents.on(EVENTS.DOMAIN.ITEM_USED, async ({ userId, itemId, tx }) => { - await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, tx); + systemEvents.on(EVENTS.DOMAIN.ITEM_USED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.ITEM_USED]) => { + await questService.handleEvent(payload.userId, `ITEM_USE:${payload.itemId}`, 1, payload.tx); }); // --- Dashboard event recording (fire-and-forget) --- - systemEvents.on(EVENTS.DOMAIN.TRANSFER_COMPLETED, async ({ username, amount, toUserId }) => { - await dashboardService.recordEvent({ - type: 'info', - message: `${username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`, - icon: '💸' + systemEvents.on(EVENTS.DOMAIN.TRANSFER_COMPLETED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRANSFER_COMPLETED]) => { + fireAndForget(async () => { + await dashboardService.recordEvent({ + type: 'info', + message: `${payload.username} transferred ${payload.amount.toLocaleString()} AU to User ID ${payload.toUserId}`, + icon: '💸' + }); }); }); - systemEvents.on(EVENTS.DOMAIN.DAILY_CLAIMED, async ({ username, amount }) => { - await dashboardService.recordEvent({ - type: 'success', - message: `${username} claimed daily reward: ${amount.toLocaleString()} AU`, - icon: '☀️' + systemEvents.on(EVENTS.DOMAIN.DAILY_CLAIMED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.DAILY_CLAIMED]) => { + fireAndForget(async () => { + await dashboardService.recordEvent({ + type: 'success', + message: `${payload.username} claimed daily reward: ${payload.amount.toLocaleString()} AU`, + icon: '☀️' + }); }); }); - systemEvents.on(EVENTS.DOMAIN.TRIVIA_STARTED, async ({ username, difficulty }) => { - await dashboardService.recordEvent({ - type: 'info', - message: `${username} started a trivia game (${difficulty})`, - icon: '🎯' + systemEvents.on(EVENTS.DOMAIN.TRIVIA_STARTED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRIVIA_STARTED]) => { + fireAndForget(async () => { + await dashboardService.recordEvent({ + type: 'info', + message: `${payload.username} started a trivia game (${payload.difficulty})`, + icon: '🎯' + }); }); }); - systemEvents.on(EVENTS.DOMAIN.TRIVIA_WON, async ({ username, reward }) => { - await dashboardService.recordEvent({ - type: 'success', - message: `${username} won ${reward.toLocaleString()} AU from trivia!`, - icon: '🎉' + systemEvents.on(EVENTS.DOMAIN.TRIVIA_WON, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRIVIA_WON]) => { + fireAndForget(async () => { + await dashboardService.recordEvent({ + type: 'success', + message: `${payload.username} won ${payload.reward.toLocaleString()} AU from trivia!`, + icon: '🎉' + }); }); }); - systemEvents.on(EVENTS.DOMAIN.EXAM_PASSED, async ({ username, reward }) => { - await dashboardService.recordEvent({ - type: 'success', - message: `${username} passed their exam: ${reward.toLocaleString()} AU`, - icon: '🎓' + systemEvents.on(EVENTS.DOMAIN.EXAM_PASSED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.EXAM_PASSED]) => { + fireAndForget(async () => { + await dashboardService.recordEvent({ + type: 'success', + message: `${payload.username} passed their exam: ${payload.reward.toLocaleString()} AU`, + icon: '🎓' + }); }); }); } diff --git a/shared/lib/events.ts b/shared/lib/events.ts index 1f0c0ec..6193102 100644 --- a/shared/lib/events.ts +++ b/shared/lib/events.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import type { Transaction } from "@shared/lib/types"; /** * Global system event bus for cross-module communication. @@ -46,3 +47,15 @@ export const EVENTS = { EXAM_PASSED: "domain:exam_passed", }, } as const; + +export interface DomainEventPayloads { + [EVENTS.DOMAIN.BALANCE_CHANGED]: { userId: string; type: string; tx: Transaction }; + [EVENTS.DOMAIN.XP_GAINED]: { userId: string; amount: number; tx: Transaction }; + [EVENTS.DOMAIN.ITEM_COLLECTED]: { userId: string; itemId: number; quantity: number; tx: Transaction }; + [EVENTS.DOMAIN.ITEM_USED]: { userId: string; itemId: number; tx: Transaction }; + [EVENTS.DOMAIN.TRANSFER_COMPLETED]: { username: string; amount: bigint; toUserId: string }; + [EVENTS.DOMAIN.DAILY_CLAIMED]: { username: string; amount: bigint }; + [EVENTS.DOMAIN.TRIVIA_STARTED]: { username: string; difficulty: string }; + [EVENTS.DOMAIN.TRIVIA_WON]: { username: string | undefined; reward: bigint }; + [EVENTS.DOMAIN.EXAM_PASSED]: { username: string; reward: bigint }; +}