Skip to content

Event Bus Architecture

Overview

Meister Bill implements a type-safe event bus pattern for decoupled component communication across both frontend (web) and backend (API). This architecture enables flexible integration with multiple notification channels (toasts, Discord, WebSockets, etc.) and automatic audit logging without tight coupling between components.

New Generic Event System (v2.0) ✨

As of Issue #188, Meister Bill has transitioned to a generic event system that provides a unified approach to event handling across all entities:

  • Event Types: create, update, delete, notify
  • Entities: user, customer, invoice, product, payment, settings, offer, project, address, document
  • Structure: All events follow a consistent structure with type, entity, entityId, actor, details, and optional metadata
  • Audit Trail: Events automatically generate audit logs via the AuditSubscriber

Benefits of the Generic System

  1. Consistency: Same event structure across all entities
  2. Scalability: Easy to add new entities without new event types
  3. Decoupled Auditing: Audit logic is now a subscriber, not embedded in controllers
  4. Type Safety: Full TypeScript support with Zod validation
  5. Flexibility: Metadata field for additional context (IP, user agent, session ID, etc.)
  6. Backend Support: Event bus now works on both frontend and backend

Generic Event Structure

{
  id?: string;              // Optional UUID, auto-generated
  type: 'create' | 'update' | 'delete' | 'notify';
  entity: 'user' | 'customer' | 'invoice' | ...;
  entityId: string;         // UUID of affected entity
  actor: string;            // UUID of user who triggered the event
  details: Record<string, any>;  // Event-specific details
  timestamp: Date;          // Auto-generated
  metadata?: {              // Optional metadata
    ip?: string;
    userAgent?: string;
    sessionId?: string;
  }
}

Architecture Diagram

┌─────────────────┐
│   Component     │
│  (Invoice List) │
└────────┬────────┘
         │ emit(EVENT_PAYMENT_BOOKED, data)
         ▼
┌─────────────────┐
│   Event Bus     │
│  (useEventBus)  │
└────────┬────────┘
         │
         ├──────────────┬──────────────┬──────────────┐
         ▼              ▼              ▼              ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│    Toast     │ │   Discord    │ │  WebSocket   │ │    Custom    │
│  Subscriber  │ │  Subscriber  │ │  Subscriber  │ │  Subscriber  │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘

Key Benefits

  1. Decoupling: Components don't need to know about notification mechanisms
  2. Extensibility: Add new subscribers without modifying existing code
  3. Type Safety: Full TypeScript support with Zod schema validation
  4. Testability: Easy to mock event bus for unit tests
  5. Centralized Logic: All notification logic in one place
  6. Multiple Channels: One event can trigger multiple notifications

Core Components

1. Event Schema (packages/schemas/src/EventSchema.ts)

Defines all application events with full type safety using Zod:

// Event type constants
export const EVENT_PAYMENT_BOOKED = "payment.booked";
export const EVENT_INVOICE_CREATED = "invoice.created";
// ... more events

// Typed event schemas
export const PaymentBookedEventSchema = BaseEventSchema.extend({
  type: z.literal(EVENT_PAYMENT_BOOKED),
  data: z.object({
    invoiceId: z.string().uuid(),
    amount: z.number(),
    currency: z.string(),
    invoiceNumber: z.string().optional(),
  }),
});

// Union of all events for type discrimination
export const AppEventSchema = z.discriminatedUnion("type", [
  PaymentBookedEventSchema,
  InvoiceCreatedEventSchema,
  // ... more events
]);

2. Event Bus (apps/web/composables/useEventBus.ts)

Singleton event emitter using mitt library:

export const useEventBus = () => {
  const emit = <T extends AppEvent["type"]>(
    type: T,
    event: Omit<Extract<AppEvent, { type: T }>, "type">
  ) => {
    // Emits type-safe events
  };

  const on = <T extends AppEvent["type"]>(
    type: T,
    handler: (event: Extract<AppEvent, { type: T }>) => void
  ) => {
    // Subscribe to events with type safety
  };

  return { emit, on, off, all };
};

3. Subscribers Plugin (apps/web/plugins/05.EventBusSubscribers.ts)

Initializes all subscribers on app startup:

  • Toast Subscriber (always active): Shows UI notifications
  • Discord Subscriber (optional): Posts to Discord webhook
  • WebSocket Subscriber (optional): Broadcasts to WebSocket clients

Available Events

Payment Events

  • payment.booked - Payment successfully booked
  • Data: invoiceId, amount, currency, invoiceNumber
  • payment.failed - Payment booking failed
  • Data: invoiceId, amount, error

Invoice Events

  • invoice.created - New invoice created
  • Data: invoiceId, invoiceNumber, customerName
  • invoice.updated - Invoice updated
  • Data: invoiceId, invoiceNumber
  • invoice.deleted - Invoice deleted
  • Data: invoiceId, invoiceNumber
  • invoice.cloned - Invoice cloned
  • Data: originalInvoiceId, newInvoiceId, newInvoiceNumber

Customer Events

  • customer.created - New customer created
  • Data: customerId, customerName
  • customer.updated - Customer updated
  • Data: customerId, customerName
  • customer.deleted - Customer deleted
  • Data: customerId, customerName

Authentication Events

  • auth.login.success - User logged in
  • Data: email
  • auth.login.failed - Login failed
  • Data: email, error
  • auth.logout - User logged out
  • Data: email (optional)
  • auth.register.success - User registered
  • Data: email
  • auth.register.failed - Registration failed
  • Data: email, error

Usage Examples

Emitting Events

// In any component or composable
import { useEventBus } from "~/composables/useEventBus";
import { EVENT_PAYMENT_BOOKED } from "@meisterbill/schemas";

const eventBus = useEventBus();

// Emit an event
eventBus.emit(EVENT_PAYMENT_BOOKED, {
  data: {
    invoiceId: invoice.id,
    amount: 100.00,
    currency: "EUR",
    invoiceNumber: "INV-2025-001"
  }
});

Creating a Custom Subscriber

Create a new file in apps/web/subscribers/:

// apps/web/subscribers/slackSubscriber.ts
import { EVENT_PAYMENT_BOOKED } from "@meisterbill/schemas";

export const slackSubscriber = () => {
  const eventBus = useEventBus();
  const config = useRuntimeConfig();

  const webhookUrl = config.public.slackWebhookUrl;
  if (!webhookUrl) {
    console.debug("[SlackSubscriber] Skipped - No webhook configured");
    return;
  }

  eventBus.on(EVENT_PAYMENT_BOOKED, async (event) => {
    await fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: `💰 Payment received: ${event.data.amount} ${event.data.currency}`,
      }),
    });
  });
};

Then enable it in apps/web/plugins/05.EventBusSubscribers.ts:

import { slackSubscriber } from "~/subscribers/slackSubscriber";

export default defineNuxtPlugin(() => {
  toastSubscriber();
  slackSubscriber(); // Add your subscriber
});

Subscribing to Events in Components

// In a Vue component
const eventBus = useEventBus();

onMounted(() => {
  eventBus.on(EVENT_PAYMENT_BOOKED, (event) => {
    console.log(`Payment received: ${event.data.amount}`);
    // Update UI, refresh data, etc.
  });
});

// Don't forget to clean up
onUnmounted(() => {
  eventBus.off(EVENT_PAYMENT_BOOKED, handler);
});

Adding New Event Types

  1. Define the event in schema (packages/schemas/src/EventSchema.ts):
export const EVENT_PRODUCT_CREATED = "product.created";

export const ProductCreatedEventSchema = BaseEventSchema.extend({
  type: z.literal(EVENT_PRODUCT_CREATED),
  data: z.object({
    productId: z.string().uuid(),
    productName: z.string(),
    price: z.number(),
  }),
});

// Add to union
export const AppEventSchema = z.discriminatedUnion("type", [
  // ... existing events
  ProductCreatedEventSchema,
]);

export type ProductCreatedEvent = z.infer<typeof ProductCreatedEventSchema>;
  1. Rebuild schemas package:
pnpm --filter @meisterbill/schemas build
  1. Emit the event in your component:
eventBus.emit(EVENT_PRODUCT_CREATED, {
  data: {
    productId: product.id,
    productName: product.name,
    price: product.price,
  }
});
  1. Add subscriber handling (optional):
// In apps/web/plugins/05.EventBusSubscribers.ts
eventBus.on(EVENT_PRODUCT_CREATED, (event) => {
  addToast({
    title: t("message.product_created_successfully"),
    description: event.data.productName,
    color: "success",
  });
});

Environment Configuration

Discord Subscriber

Set in nuxt.config.ts or .env:

NUXT_PUBLIC_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...

WebSocket Subscriber

Set in nuxt.config.ts or .env:

NUXT_PUBLIC_WEBSOCKET_URL=wss://your-websocket-server.com

Best Practices

  1. Always use event constants: Import from @meisterbill/schemas
  2. Keep event data minimal: Only include necessary information
  3. Don't block on event handlers: Keep handlers fast and async
  4. Use descriptive event names: Follow pattern resource.action
  5. Version events if needed: Add v2 suffix for breaking changes
  6. Log events in debug mode: Use console.debug for visibility
  7. Test event emissions: Mock the event bus in unit tests
  8. Document event data: Keep this doc updated with new events

Testing

Mocking the Event Bus

// In a test file
import { describe, it, expect, vi } from "vitest";

describe("Invoice List", () => {
  it("emits payment booked event", async () => {
    const mockEmit = vi.fn();
    vi.mock("~/composables/useEventBus", () => ({
      useEventBus: () => ({
        emit: mockEmit,
        on: vi.fn(),
        off: vi.fn(),
      }),
    }));

    // ... test code

    expect(mockEmit).toHaveBeenCalledWith(EVENT_PAYMENT_BOOKED, {
      data: expect.objectContaining({
        invoiceId: expect.any(String),
        amount: 100,
      }),
    });
  });
});

Migration Guide

Before (Direct Toast Calls)

const { add: addToast } = useToast();

try {
  await bookPayment(data);
  addToast({
    title: "Payment booked",
    color: "success",
  });
} catch (error) {
  addToast({
    title: "Payment failed",
    color: "error",
  });
}

After (Event Bus)

const eventBus = useEventBus();

try {
  await bookPayment(data);
  eventBus.emit(EVENT_PAYMENT_BOOKED, {
    data: { invoiceId, amount, currency, invoiceNumber },
  });
} catch (error) {
  eventBus.emit(EVENT_PAYMENT_FAILED, {
    data: { invoiceId, amount, error: error.message },
  });
}

Troubleshooting

Events not being received

  1. Check if the subscriber is initialized in the plugin
  2. Verify event type constant is correct
  3. Check console for debug logs
  4. Ensure schemas package is built

Type errors

  1. Rebuild schemas package: pnpm --filter @meisterbill/schemas build
  2. Check TypeScript version compatibility
  3. Verify event data matches schema

Performance issues

  1. Reduce number of subscribers
  2. Make handlers async and non-blocking
  3. Debounce high-frequency events
  4. Consider event batching for bulk operations

Backend Event Bus (NEW)

The backend now has its own event bus for server-side events:

Backend Architecture

┌─────────────────┐
│   Controller    │
│  (InvoiceCtrl)  │
└────────┬────────┘
         │ emitEvent('create', 'invoice', ...)
         ▼
┌─────────────────┐
│ Backend         │
│ Event Bus       │
│  (EventBus.ts)  │
└────────┬────────┘
         │
         ├──────────────┬──────────────┐
         ▼              ▼              ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│    Audit     │ │   Email      │ │   Future     │
│  Subscriber  │ │  Subscriber  │ │  Subscribers │
└──────────────┘ └──────────────┘ └──────────────┘

Usage in Controllers

import { emitEvent } from "../services/EventBus";

// In your controller
emitEvent(
  "create",
  "invoice",
  invoice.id,
  userId,
  {
    document_number: invoice.document_number,
    ...invoice,
  }
);

Audit Subscriber

The AuditSubscriber automatically logs all create, update, and delete events to the database:

  • Initialized at app startup
  • Calculates diffs for update events
  • Stores events in audit table
  • Non-blocking (errors don't break the app)

Adding Backend Subscribers

Create a new subscriber in apps/api/src/subscribers/:

// apps/api/src/subscribers/EmailNotificationSubscriber.ts
import { eventBus } from "../services/EventBus";

export const initializeEmailNotificationSubscriber = (): void => {
  eventBus.onTypeAndEntity("create", "invoice", async (event) => {
    // Send email notification
    await sendInvoiceCreatedEmail(event.entityId);
  });

  console.debug("[EmailNotificationSubscriber] Initialized");
};

Then initialize it in apps/api/src/index.ts:

import { initializeAuditSubscriber } from "./subscribers/AuditSubscriber";
import { initializeEmailNotificationSubscriber } from "./subscribers/EmailNotificationSubscriber";

(async () => {
  await SessionHandler.createSessionDatabaseIfNotExists();
  initializeAuditSubscriber();
  initializeEmailNotificationSubscriber();
})();

Database Migration

To use the new generic event system, run the migration:

-- Location: database/migrations/001_audit_table_refactor.sql
-- Adds: event_type, entity, entity_id, metadata columns
-- Keeps: old columns for backward compatibility

Future Enhancements

  • [x] Event persistence to database (via AuditSubscriber)
  • [x] Server-side event bus for API
  • [ ] Event replay functionality
  • [ ] Event filtering by user/tenant
  • [ ] Event analytics and monitoring
  • [ ] Event-driven state management
  • [ ] Event versioning system
  • [ ] GraphQL subscription support
  • [ ] Real-time event streaming between backend and frontend