Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.14.0] - 2026-05-22

### Added
- Notifications module (EXTENDED): `provideNotifications(options?)` factory with `NOTIFICATION_CONFIG` injection token
- `NotificationService` — central event dispatcher with adapter registry, `emit()` routing via `supports()`, and `Promise.allSettled` fault tolerance
- `NotificationAdapter` interface — contract for pluggable notification channels (`name`, `supports()`, `send()`)
- `FireflyEvent` type — system event payload with `type`, `product`, `severity`, `message`, `timestamp`, and optional `data`
- `ConsoleAdapter` — always-on fallback adapter, logs events to console with severity-aware formatting
- `NoopAdapter` — silent no-op adapter for testing environments
- `SlackAdapter` — dispatches events to Slack via incoming webhook URL with severity emoji formatting
- `WebhookAdapter` — generic HTTP POST adapter with configurable URL and custom headers
- `EmailAdapter` — dispatches events to an HTTP relay endpoint with email-specific payload (from, subject, body)
- Types: `EventSeverity`, `SlackAdapterOptions`, `EmailAdapterOptions`, `WebhookAdapterOptions`, `NotificationOptions`, `NotificationConfig`

## [0.13.0] - 2026-05-22

### Added
Expand Down Expand Up @@ -200,7 +214,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- User context module: `UserContextService`
- API runtime module: `ApiClient`, `TransportRegistry`, `HttpTransportAdapter`

[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.11.0...HEAD
[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.14.0...HEAD
[0.14.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.13.0...core@0.14.0
[0.13.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.12.0...core@0.13.0
[0.12.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.11.0...core@0.12.0
[0.11.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.10.1...core@0.11.0
[0.10.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.9.0...core@0.10.0
[0.9.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.8.0...core@0.9.0
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
"version": "0.13.0",
"version": "0.14.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './lib/storage';
export * from './lib/environment';
export * from './lib/security';
export * from './lib/files';
export * from './lib/notifications';
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ConsoleAdapter } from './console-adapter';
import { FireflyEvent } from '../notification.types';

function createEvent(overrides?: Partial<FireflyEvent>): FireflyEvent {
return {
type: 'test.event',
product: 'test-app',
severity: 'info',
message: 'Test message',
timestamp: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

describe('ConsoleAdapter', () => {
let adapter: ConsoleAdapter;

beforeEach(() => {
adapter = new ConsoleAdapter();
});

it('should have name "console"', () => {
expect(adapter.name).toBe('console');
});

it('should support all event types', () => {
expect(adapter.supports('any.event')).toBe(true);
expect(adapter.supports('error.critical')).toBe(true);
expect(adapter.supports('')).toBe(true);
});

it('should log info events with console.log', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
await adapter.send(createEvent({ severity: 'info' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toContain('[INFO]');
expect(spy.mock.calls[0][0]).toContain('test-app');
expect(spy.mock.calls[0][0]).toContain('test.event');
spy.mockRestore();
});

it('should log warning events with console.warn', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
await adapter.send(createEvent({ severity: 'warning' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toContain('[WARN]');
spy.mockRestore();
});

it('should log error events with console.error', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(vi.fn());
await adapter.send(createEvent({ severity: 'error' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toContain('[ERROR]');
spy.mockRestore();
});

it('should log critical events with console.error', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(vi.fn());
await adapter.send(createEvent({ severity: 'critical' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toContain('[CRITICAL]');
spy.mockRestore();
});

it('should include event data when present', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
const data = { key: 'value' };
await adapter.send(createEvent({ data }));
expect(spy.mock.calls[0][1]).toEqual(data);
spy.mockRestore();
});

it('should pass empty string when no data', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
await adapter.send(createEvent());
expect(spy.mock.calls[0][1]).toBe('');
spy.mockRestore();
});

it('should format the log line correctly', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
await adapter.send(createEvent({
severity: 'info',
product: 'my-app',
type: 'deploy.complete',
message: 'Deployment finished',
}));
expect(spy.mock.calls[0][0]).toBe('[INFO] [my-app] deploy.complete: Deployment finished');
spy.mockRestore();
});
});
42 changes: 42 additions & 0 deletions packages/core/src/lib/notifications/adapters/console-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FireflyEvent, NotificationAdapter } from '../notification.types';

const SEVERITY_PREFIX: Record<string, string> = {
info: '[INFO]',
warning: '[WARN]',
error: '[ERROR]',
critical: '[CRITICAL]',
};

/**
* Adapter that logs events to the browser console.
*
* Always registered as a fallback — accepts all event types.
* Formats output as: `[SEVERITY] [product] type: message`
*
* @example
* ```typescript
* const adapter = new ConsoleAdapter();
* adapter.supports('any.event'); // true
* await adapter.send(event); // logs to console
* ```
*/
export class ConsoleAdapter implements NotificationAdapter {
readonly name = 'console';

supports(): boolean {
return true;
}

async send(event: FireflyEvent): Promise<void> {
const prefix = SEVERITY_PREFIX[event.severity] ?? '[INFO]';
const line = `${prefix} [${event.product}] ${event.type}: ${event.message}`;

if (event.severity === 'error' || event.severity === 'critical') {
console.error(line, event.data ?? '');
} else if (event.severity === 'warning') {
console.warn(line, event.data ?? '');
} else {
console.log(line, event.data ?? '');
}
}
}
76 changes: 76 additions & 0 deletions packages/core/src/lib/notifications/adapters/email-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { EmailAdapter } from './email-adapter';
import { FireflyEvent } from '../notification.types';

function createEvent(overrides?: Partial<FireflyEvent>): FireflyEvent {
return {
type: 'test.event',
product: 'test-app',
severity: 'info',
message: 'Test message',
timestamp: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

describe('EmailAdapter', () => {
let adapter: EmailAdapter;
let fetchSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
adapter = new EmailAdapter({
smtpEndpoint: 'https://api.internal/send-email',
from: 'noreply@acme.com',
});
fetchSpy = vi.fn().mockResolvedValue(new Response('ok'));
vi.stubGlobal('fetch', fetchSpy);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should have name "email"', () => {
expect(adapter.name).toBe('email');
});

it('should support all event types', () => {
expect(adapter.supports('any')).toBe(true);
});

it('should POST to the smtp endpoint', async () => {
await adapter.send(createEvent());
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('https://api.internal/send-email');
expect(fetchSpy.mock.calls[0][1].method).toBe('POST');
});

it('should include from address in payload', async () => {
await adapter.send(createEvent());
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.from).toBe('noreply@acme.com');
});

it('should format subject with severity, product and type', async () => {
await adapter.send(createEvent({
severity: 'critical',
product: 'lending',
type: 'error.db',
}));
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.subject).toBe('[CRITICAL] [lending] error.db');
});

it('should include message as body', async () => {
await adapter.send(createEvent({ message: 'DB connection lost' }));
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.body).toBe('DB connection lost');
});

it('should include data and timestamp', async () => {
const data = { pool: 'primary' };
await adapter.send(createEvent({ data, timestamp: '2026-06-01T12:00:00Z' }));
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.data).toEqual({ pool: 'primary' });
expect(body.timestamp).toBe('2026-06-01T12:00:00Z');
});
});
42 changes: 42 additions & 0 deletions packages/core/src/lib/notifications/adapters/email-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FireflyEvent, NotificationAdapter, EmailAdapterOptions } from '../notification.types';

/**
* Adapter that sends events as email via an HTTP relay endpoint.
*
* The browser cannot do SMTP directly, so this adapter POSTs an email
* payload to an HTTP relay service that handles the actual sending.
*
* @example
* ```typescript
* const adapter = new EmailAdapter({
* smtpEndpoint: 'https://api.internal/send-email',
* from: 'noreply@acme.com',
* });
* await adapter.send(event);
* ```
*/
export class EmailAdapter implements NotificationAdapter {
readonly name = 'email';

constructor(private readonly options: EmailAdapterOptions) {}

supports(): boolean {
return true;
}

async send(event: FireflyEvent): Promise<void> {
const subject = `[${event.severity.toUpperCase()}] [${event.product}] ${event.type}`;

await fetch(this.options.smtpEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: this.options.from,
subject,
body: event.message,
data: event.data,
timestamp: event.timestamp,
}),
});
}
}
44 changes: 44 additions & 0 deletions packages/core/src/lib/notifications/adapters/noop-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NoopAdapter } from './noop-adapter';
import { FireflyEvent } from '../notification.types';

function createEvent(overrides?: Partial<FireflyEvent>): FireflyEvent {
return {
type: 'test.event',
product: 'test-app',
severity: 'info',
message: 'Test message',
timestamp: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

describe('NoopAdapter', () => {
let adapter: NoopAdapter;

beforeEach(() => {
adapter = new NoopAdapter();
});

it('should have name "noop"', () => {
expect(adapter.name).toBe('noop');
});

it('should support all event types', () => {
expect(adapter.supports('any.event')).toBe(true);
expect(adapter.supports('')).toBe(true);
});

it('should resolve without doing anything', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn());
await expect(adapter.send(createEvent())).resolves.toBeUndefined();
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('should handle multiple sends without side effects', async () => {
await adapter.send(createEvent());
await adapter.send(createEvent({ severity: 'critical' }));
await adapter.send(createEvent({ type: 'error.fatal' }));
// If we got here without error, test passes
});
});
26 changes: 26 additions & 0 deletions packages/core/src/lib/notifications/adapters/noop-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { FireflyEvent, NotificationAdapter } from '../notification.types';

/**
* Adapter that does nothing — for use in test environments.
*
* Accepts all event types and resolves immediately without side effects.
*
* @example
* ```typescript
* const adapter = new NoopAdapter();
* adapter.supports('any.event'); // true
* await adapter.send(event); // no-op
* ```
*/
export class NoopAdapter implements NotificationAdapter {
readonly name = 'noop';

supports(): boolean {
return true;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async send(_event: FireflyEvent): Promise<void> {
// intentionally empty
}
}
Loading