-
Notifications
You must be signed in to change notification settings - Fork 0
NOTIFICATIONS MODULE
Dispatch system events to multiple external channels via the Adapter pattern: console, Slack, generic webhook, and email relay. Package:
@fireflyframework/core· EXTENDED Module #2 · Platform + Embedded
- 1. Overview
- 2. Developer Usage Guide
- 3. API Reference
- 4. Internal Technical Architecture
- 5. Advanced Usage Patterns
The Notifications module provides an event dispatch system based on the Adapter pattern. Each system event (FireflyEvent) is emitted through NotificationService and routed to one or more external channels (console, Slack, webhook, email) based on the registered adapters.
-
NotificationService — central orchestrator that maintains a registry of adapters and dispatches events to all of them that support the event type. Uses
Promise.allSettledfor fault tolerance -
ConsoleAdapter — always-active adapter that formats events in the browser console with a severity prefix (
[INFO],[WARN],[ERROR],[CRITICAL]) - NoopAdapter — empty adapter for test environments. Accepts everything, does nothing
- SlackAdapter — sends events to a Slack webhook with severity-based emoji formatting
-
WebhookAdapter — sends the full
FireflyEventas JSON to a generic HTTP endpoint. Supports custom headers - EmailAdapter — sends events as email through an HTTP relay endpoint (the browser cannot do SMTP directly)
- It does not implement a notification center UI — that is the responsibility of the product layer
- It does not manage push notifications or WebSockets — only one-way dispatch
- It does not implement retry queues — if an adapter fails, the event is lost for that channel
- It does not implement event persistence — there is no server-side history
- It does not filter by user or role — events are at the system level, not the user level
- It does not implement rate limiting or deduplication
Every product needs to notify system events to external channels: critical errors to Slack, deployments to a CI webhook, alerts via email. Without a unified module, each product reimplements the integration with every channel, duplicating severity parsers, message formatting, and HTTP error handling.
The Notifications module provides:
- A single contract (
NotificationAdapter) that abstracts any external channel - Selective routing via
supports(eventType)so each adapter filters events - Fault tolerance via
Promise.allSettled— one failing adapter does not block the others - Declarative configuration via
provideNotifications()with opt-in per channel - Extensibility: any product can create custom adapters (e.g., Sentry, PagerDuty, Teams)
EXTENDED module (not CORE): Not every application needs to dispatch events to external channels. Embedded applications (micro-frontends) typically delegate notifications to the host. For this reason, the module requires explicit registration via provideNotifications() and the services do not use providedIn: 'root'.
ConsoleAdapter always active: Even without configuring any channel, ConsoleAdapter is registered as a fallback. This guarantees that events always leave an observable trace in development.
In the original Flutter portal (soon-distributor-portal), there was no internal notification system. Errors were handled with direct prints, Slack/email integrations were done ad hoc from the backend, and there was no unified pattern for dispatching frontend events.
Firefly introduces this module as part of the product's observability layer, allowing the frontend to emit relevant events in a structured way and have them automatically routed to the configured channels.
The Notifications module is EXTENDED — it requires explicit registration:
// app.config.ts
import { provideNotifications } from '@fireflyframework/core';
export const appConfig: ApplicationConfig = {
providers: [
provideNotifications({
adapters: ['slack', 'email'],
slack: { webhookUrl: 'https://hooks.slack.com/services/...' },
email: { smtpEndpoint: 'https://api.internal/send-email', from: 'noreply@acme.com' },
}),
],
};Without arguments (provideNotifications()) it registers only ConsoleAdapter.
import { NotificationService } from '@fireflyframework/core';
@Component({ /* ... */ })
export class DeployComponent {
private readonly notifications = inject(NotificationService);
async onDeployComplete(): Promise<void> {
await this.notifications.emit({
type: 'deploy.complete',
product: 'my-app',
severity: 'info',
message: 'Deployment v2.1.0 completed successfully',
timestamp: new Date().toISOString(),
data: { version: '2.1.0', environment: 'production' },
});
}
}The event is sent to every adapter that supports the 'deploy.complete' type. The built-in adapters accept every type by default.
import { NotificationService, NotificationAdapter, FireflyEvent } from '@fireflyframework/core';
const sentryAdapter: NotificationAdapter = {
name: 'sentry',
supports: (eventType) => eventType.startsWith('error.'),
async send(event: FireflyEvent): Promise<void> {
Sentry.captureMessage(event.message, {
level: event.severity === 'critical' ? 'fatal' : event.severity,
tags: { product: event.product, type: event.type },
extra: event.data,
});
},
};
// In your component or service
const notifications = inject(NotificationService);
notifications.registerAdapter(sentryAdapter);Duplicate adapters (same name) are silently ignored.
The supports(eventType) method lets each adapter decide which events to process:
// Adapter that only processes errors
const errorOnlyAdapter: NotificationAdapter = {
name: 'error-tracker',
supports: (type) => type.startsWith('error.'),
async send(event) { /* ... */ },
};
// Adapter that processes everything except info
const criticalAdapter: NotificationAdapter = {
name: 'pagerduty',
supports: (type) => !type.startsWith('info.'),
async send(event) { /* ... */ },
};The built-in adapters (ConsoleAdapter, SlackAdapter, etc.) return true for every type — they process all events.
| Adapter | Channel | Always active | Requires config |
|---|---|---|---|
ConsoleAdapter |
console.log/warn/error |
Yes | No |
NoopAdapter |
None (test) | No | No |
SlackAdapter |
Slack webhook | No | webhookUrl |
WebhookAdapter |
Generic HTTP POST | No | url |
EmailAdapter |
HTTP relay → email | No |
smtpEndpoint, from
|
type EventSeverity = 'info' | 'warning' | 'error' | 'critical';Severity levels for system events. Determines the output format in the adapters (emoji, console method, etc.).
interface FireflyEvent {
readonly type: string; // Dot-separated identifier (e.g. 'error.unhandled')
readonly product: string; // Product or service that originated the event
readonly severity: EventSeverity;
readonly message: string; // Human-readable message
readonly timestamp: string; // ISO-8601
readonly data?: Record<string, unknown>; // Arbitrary payload
}interface NotificationAdapter {
readonly name: string;
supports(eventType: string): boolean;
send(event: FireflyEvent): Promise<void>;
}Contract that every adapter must implement. supports() enables selective routing.
interface NotificationOptions {
readonly adapters?: ReadonlyArray<'slack' | 'email' | 'webhook'>;
readonly slack?: SlackAdapterOptions;
readonly email?: EmailAdapterOptions;
readonly webhook?: WebhookAdapterOptions;
}Input for provideNotifications(). All fields are optional.
interface NotificationConfig {
readonly enabledAdapters: ReadonlyArray<string>;
readonly slack?: SlackAdapterOptions;
readonly email?: EmailAdapterOptions;
readonly webhook?: WebhookAdapterOptions;
}Resolved configuration stored in the DI container. Injected via NOTIFICATION_CONFIG.
interface SlackAdapterOptions {
readonly webhookUrl: string;
}interface EmailAdapterOptions {
readonly smtpEndpoint: string;
readonly from: string;
}interface WebhookAdapterOptions {
readonly url: string;
readonly headers?: Record<string, string>;
}Import: import { NotificationService } from '@fireflyframework/core';
Registration: Via provideNotifications() (@Injectable(), not providedIn: 'root')
| Method | Signature | Description |
|---|---|---|
registerAdapter |
(adapter: NotificationAdapter) => void |
Registers an adapter. Duplicates (same name) are ignored |
emit |
(event: FireflyEvent) => Promise<void> |
Emits an event to every adapter that supports it. Uses Promise.allSettled
|
getAdapters |
() => ReadonlyArray<NotificationAdapter> |
Returns a read-only snapshot of the registered adapters |
Import: import { ConsoleAdapter } from '@fireflyframework/core';
Adapter that logs events to the browser console. Always registered as a fallback.
-
name:'console' -
supports(): alwaystrue -
send(): formats as[SEVERITY] [product] type: message. Usesconsole.errorfor error/critical,console.warnfor warning,console.logfor info
Import: import { NoopAdapter } from '@fireflyframework/core';
Adapter for test environments. Accepts every event and resolves immediately without side effects.
-
name:'noop' -
supports(): alwaystrue -
send(): no-op (resolves immediately)
It is not registered automatically — it requires a manual registerAdapter() call.
Import: import { SlackAdapter } from '@fireflyframework/core';
Sends events to a Slack webhook. Formats with a severity emoji.
-
name:'slack' -
supports(): alwaystrue -
send(): POST towebhookUrlwith body{ text: "emoji [product] type: message" } - Constructor:
new SlackAdapter(options: SlackAdapterOptions)
Emojis by severity: info → ℹ️, warning →
Import: import { WebhookAdapter } from '@fireflyframework/core';
Sends the full FireflyEvent as JSON to a generic HTTP endpoint.
-
name:'webhook' -
supports(): alwaystrue -
send(): POST tourlwith body =JSON.stringify(event). Includes optionalheaders - Constructor:
new WebhookAdapter(options: WebhookAdapterOptions)
Import: import { EmailAdapter } from '@fireflyframework/core';
Sends events as email through an HTTP relay endpoint.
-
name:'email' -
supports(): alwaystrue -
send(): POST tosmtpEndpointwith body{ from, subject, body, data, timestamp } - Constructor:
new EmailAdapter(options: EmailAdapterOptions)
Subject format: [SEVERITY] [product] type
function provideNotifications(options?: NotificationOptions): EnvironmentProvidersRegisters NotificationService, NOTIFICATION_CONFIG, and the selected adapters.
Behavior:
- Always registers
ConsoleAdapter(fallback) - If
options.adaptersincludes'slack'andoptions.slackexists → registersSlackAdapter - If
options.adaptersincludes'webhook'andoptions.webhookexists → registersWebhookAdapter - If
options.adaptersincludes'email'andoptions.emailexists → registersEmailAdapter
Registration runs in APP_INITIALIZER so that adapters are available before components start up.
const NOTIFICATION_CONFIG: InjectionToken<NotificationConfig>Injection token for the module's resolved configuration. Provided by provideNotifications().
-
Adapter pattern — every external channel implements
NotificationAdapter. The service does not know the details of each channel -
Fault tolerance —
Promise.allSettledinemit()guarantees that one failing adapter does not block the others -
Selective routing —
supports(eventType)lets adapters filter events without coupling that logic to the service -
Opt-in per channel — only the adapters declared in
options.adaptersare instantiated -
Guaranteed fallback —
ConsoleAdapteris always active for minimal observability
packages/core/src/lib/notifications/
├── index.ts # Public API (16 exports)
├── notification.types.ts # Types, interfaces, InjectionToken
├── notification.service.ts # NotificationService (orchestrator)
├── notification.service.spec.ts # Service tests
├── provide-notifications.ts # provideNotifications() factory
├── provide-notifications.spec.ts # Provider tests
└── adapters/
├── console-adapter.ts # ConsoleAdapter
├── console-adapter.spec.ts
├── noop-adapter.ts # NoopAdapter
├── noop-adapter.spec.ts
├── slack-adapter.ts # SlackAdapter
├── slack-adapter.spec.ts
├── webhook-adapter.ts # WebhookAdapter
├── webhook-adapter.spec.ts
├── email-adapter.ts # EmailAdapter
└── email-adapter.spec.ts
provideNotifications(options)
│
├─ creates NotificationConfig
├─ registers NotificationService
└─ APP_INITIALIZER:
├─ registerAdapter(ConsoleAdapter) ← always
├─ registerAdapter(SlackAdapter) ← if 'slack' in adapters
├─ registerAdapter(WebhookAdapter) ← if 'webhook' in adapters
└─ registerAdapter(EmailAdapter) ← if 'email' in adapters
emit(event: FireflyEvent)
│
├─ filters adapters → adapter.supports(event.type)
├─ targets = adapters that accept
└─ Promise.allSettled(targets.map(a => a.send(event)))
├─ ConsoleAdapter.send() → console.log/warn/error
├─ SlackAdapter.send() → fetch(webhookUrl, POST)
├─ WebhookAdapter.send() → fetch(url, POST)
└─ EmailAdapter.send() → fetch(smtpEndpoint, POST)
The Notifications module has zero dependencies on other framework modules:
- Does not depend on Angular's
HttpClient(uses nativefetch()) - Does not depend on the Transport/Network module
- Does not depend on the Storage or Persistence modules
- Does not depend on the Environment module
This independence is deliberate: the module must work in any Angular context without requiring prior configuration of other modules.
| Decision | Discarded alternative | Reason |
|---|---|---|
Native fetch() in HTTP adapters |
Angular's HttpClient
|
Reduces coupling. Adapters are plain classes (no @Injectable); they do not need DI |
Promise.allSettled in emit() |
Promise.all with try/catch |
allSettled never rejects, guaranteeing that every adapter runs |
| Adapters as plain classes | Adapters as Angular services | Adapters are instantiated in APP_INITIALIZER with new, not via DI. Keeps things simple |
supports() on each adapter |
Centralized filters in the service | It is the adapter's responsibility to decide which events it accepts. Follows the Open/Closed Principle |
| Duplicates silently ignored | Error or warning | Prevents accidental double-registration on reinitialization without noise |
| Module | Relationship |
|---|---|
| Alerts | Complementary: Notifications dispatches to external channels (Slack, email). Alerts manages internal UI (dialogs, toasts). A product can create a bridge adapter that connects both (AlertAdapter) |
| Error Handling | The product's error handler can use NotificationService.emit() to report errors to external channels |
| Environment | The product can read configuration URLs from environment and pass them to provideNotifications()
|
| Storage | No direct relationship. A future EventBus module (Step 2.14) will cover persistence of internal events |
Connects the notifications pipeline to the framework's UI alerts system:
import { NotificationAdapter, FireflyEvent } from '@fireflyframework/core';
import { AlertService } from '@fireflyframework/core';
function createAlertAdapter(alerts: AlertService): NotificationAdapter {
return {
name: 'alert',
supports: () => true,
async send(event: FireflyEvent): Promise<void> {
const type = event.severity === 'critical' || event.severity === 'error' ? 'error'
: event.severity === 'warning' ? 'warning' : 'info';
await alerts.dialog({
title: `[${event.severity.toUpperCase()}] ${event.type}`,
message: event.message,
type: type as 'info' | 'warning' | 'error' | 'success',
confirmLabel: 'OK',
});
},
};
}Register adapters in a feature module for feature-specific notifications:
@Component({ /* ... */ })
export class LendingDashboardComponent implements OnInit {
private readonly notifications = inject(NotificationService);
ngOnInit(): void {
this.notifications.registerAdapter({
name: 'lending-audit',
supports: (type) => type.startsWith('lending.'),
async send(event) {
// Send only lending events to a specific audit trail
await fetch('/api/audit/lending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
},
});
}
}// error-handler.service.ts
@Injectable({ providedIn: 'root' })
export class AppErrorHandler {
private readonly notifications = inject(NotificationService);
private readonly env = inject(EnvironmentService);
async reportError(error: Error, context?: Record<string, unknown>): Promise<void> {
await this.notifications.emit({
type: 'error.unhandled',
product: this.env.get('APP_NAME'),
severity: 'error',
message: error.message,
timestamp: new Date().toISOString(),
data: { stack: error.stack, ...context },
});
}
}describe('FeatureComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideNotifications()],
});
const service = TestBed.inject(NotificationService);
service.registerAdapter(new NoopAdapter());
});
it('should emit event without side effects', async () => {
const service = TestBed.inject(NotificationService);
// NoopAdapter accepts everything and does nothing — safe for tests
await service.emit({ /* ... */ });
});
});- Alerts Module