Fix notifications surface ownership and frontdoor contracts

This commit is contained in:
master
2026-03-10 16:54:25 +02:00
parent 2859c751e6
commit 8578065675
15 changed files with 1820 additions and 1182 deletions

View File

@@ -79,6 +79,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export$1" }, { "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" }, { "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },

View File

@@ -0,0 +1,77 @@
# Sprint 20260310-029 - Notifications Surface Contract And Frontdoor Split
## Topic & Scope
- Restore the intended split between operator notifications and setup/admin notifications so `/ops/operations/notifications` stays an operator workflow while `/setup/notifications` hosts Notifications Studio.
- Repair the Notifications Studio web client so it talks to the documented Notifier frontdoor instead of stale legacy Notify endpoint shapes and paths.
- Add the missing router frontdoor mapping for the Notifier Studio API prefix and reverify both surfaces with focused tests and live Playwright.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: focused Angular specs, focused router tests, live Playwright artifact, updated sprint log.
## Dependencies & Concurrency
- Depends on `SPRINT_20260310_028_FE_route_surface_ownership_alignment.md` for the route ownership baseline.
- Safe parallelism: avoid the unrelated dirty files already present under `src/Web/StellaOps.Web/src/app/features/approvals/`, `src/Web/StellaOps.Web/src/app/features/release-control/`, `src/Web/StellaOps.Web/src/app/features/security/`, `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/`, and `docs/implplan/SPRINT_20260310_026_Platform_global_context_propagation_header_cleanup.md`.
- Allowed coordination edits: `src/Router/StellaOps.Gateway.WebService/appsettings.json`, `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs`, `devops/compose/router-gateway-local.json`, `docs/modules/notify/architecture.md`, `docs/implplan/SPRINT_20260310_029_FE_notifications_surface_contract_and_frontdoor_split.md`.
## Documentation Prerequisites
- `docs/modules/notify/architecture.md`
- `docs/features/checked/web/security-operations-leaves-ui.md`
- `docs/features/checked/web/notification-rule-simulation-escalation-policies.md`
- `docs/modules/router/webservices-valkey-rollout-matrix.md`
## Delivery Tracker
### NOTIFY-FRONTDOOR-029-001 - Restore route ownership for operator and admin notifications
Status: DONE
Dependency: none
Owners: QA, Developer
Task description:
- Move `/ops/operations/notifications` back onto the operator `NotifyPanelComponent` and mount Notifications Studio under `/setup/notifications` instead of redirecting setup traffic into ops.
- Update route contract specs so the ownership split is explicit and regressions are caught in tests.
Completion criteria:
- [ ] `/ops/operations/notifications` renders the operator notifications shell rather than Notifications Studio.
- [ ] `/setup/notifications` is mounted directly and no longer redirects into ops.
- [ ] Route ownership specs cover both surfaces.
### NOTIFY-FRONTDOOR-029-002 - Retarget Notifications Studio to the documented Notifier frontdoor
Status: DONE
Dependency: NOTIFY-FRONTDOOR-029-001
Owners: 3rd line support, Architect, Developer
Task description:
- Diagnose the live Studio failures down to route ownership, stale API base URL, stale endpoint paths, and response-shape mismatches.
- Retarget the web client to the Notifier frontdoor prefix, normalize live collection envelopes, and use canonical Studio endpoint names instead of stale singular and misspelled paths.
- Add the missing router mapping for the Studio frontdoor prefix so the client reaches Notifier through the gateway without reintroducing broad reverse-proxy fallback.
Completion criteria:
- [ ] The web client uses the canonical Notifier frontdoor prefix.
- [ ] Rules, channels, deliveries, quiet-hours, overrides, escalation policies, throttles, simulation, and preview calls use canonical endpoint names.
- [ ] Focused specs cover response normalization and frontdoor route presence.
### NOTIFY-FRONTDOOR-029-003 - Reverify notifications surfaces live with Playwright
Status: DONE
Dependency: NOTIFY-FRONTDOOR-029-002
Owners: QA
Task description:
- Rebuild the affected runtime slice, sync the new web bundle, and run live Playwright against both `/ops/operations/notifications` and `/setup/notifications`.
- Verify the operator watchlist handoff links render and land correctly, and verify the admin tabs load without runtime error banners or broken requests on the rebuilt stack.
Completion criteria:
- [ ] Focused Angular/router tests pass.
- [ ] The rebuilt web bundle is synced into the live stack.
- [ ] Live Playwright verifies the operator and admin notifications surfaces without the previous `t.items is not iterable` failure.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-10 | Sprint created after live Playwright showed `/ops/operations/notifications` was serving the wrong owner surface and `/setup/notifications` was coupled to stale Notifications Studio frontdoor contracts. | Developer |
| 2026-03-10 | Restored the operator/admin route split, added the router frontdoor mapping for `/api/v1/notifier/*`, corrected the accidental repointing of the operator `NotifyApi` to Notifier, rebuilt the web bundle, synced `compose_console-dist`, restarted `stellaops-router-gateway`, and reran the live Playwright notifications sweep cleanly. Focused Angular/Vitest and router tests passed before the live recheck. | Codex |
## Decisions & Risks
- Decision: keep the product split documented in the UI dossiers: ops notifications remains the operator shell, while setup notifications remains the admin Studio.
- Decision: use the documented Notifier frontdoor prefix (`/api/v1/notifier`) and route it through explicit microservice mappings instead of broad reverse-proxy fallback.
- Decision: keep the legacy operator `NotifyApi` on `/api/v1/notify`; only the admin Notifications Studio moves to `/api/v1/notifier`. Mixing those two service contracts caused the live `newCollection[Symbol.iterator] is not a function` runtime failure on the operator page.
- Risk: the Notifier Studio backend currently emits mixed collection shapes across endpoints and tests; the web client must normalize both raw-array and envelope forms until the backend contracts are fully converged.
## Next Checkpoints
- Land the route and frontdoor fixes with focused specs.
- Rebuild the router/web slice and rerun the live notifications Playwright sweep.

View File

@@ -1,5 +1,7 @@
> **Scope.** Implementationready architecture for **Notify** (aligned with Epic11 Notifications Studio): a rulesdriven, tenantaware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operatordefined routing rules, renders **channelspecific messages** (Slack/Teams/Email/Webhook), and delivers them **reliably** with idempotency, throttling, and digests. It is UImanaged, auditable, and safe by default (no secrets leakage, no spam storms). > **Scope.** Implementationready architecture for **Notify** (aligned with Epic11 Notifications Studio): a rulesdriven, tenantaware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operatordefined routing rules, renders **channelspecific messages** (Slack/Teams/Email/Webhook), and delivers them **reliably** with idempotency, throttling, and digests. It is UImanaged, auditable, and safe by default (no secrets leakage, no spam storms).
* **Console frontdoor compatibility (updated 2026-03-10).** The web console reaches Notifier Studio through the gateway-owned `/api/v1/notifier/*` prefix, which translates onto the service-local `/api/v2/notify/*` surface without requiring browser calls to raw service-prefixed routes.
--- ---
## 0) Mission & boundaries ## 0) Mission & boundaries

View File

@@ -107,6 +107,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" }, { "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" }, { "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },

View File

@@ -12,6 +12,7 @@ public sealed class GatewayRouteSearchMappingsTests
("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true), ("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true),
("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true), ("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true),
("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true), ("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true),
("^/api/v1/notifier/(.*)", "http://notifier.stella-ops.local/api/v2/notify/$1", "Microservice", true),
("^/api/v1/aoc(.*)", "http://platform.stella-ops.local/api/v1/aoc$1", "Microservice", true), ("^/api/v1/aoc(.*)", "http://platform.stella-ops.local/api/v1/aoc$1", "Microservice", true),
("^/api/v1/administration(.*)", "http://platform.stella-ops.local/api/v1/administration$1", "Microservice", true), ("^/api/v1/administration(.*)", "http://platform.stella-ops.local/api/v1/administration$1", "Microservice", true),
("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true), ("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true),

View File

@@ -85,6 +85,45 @@ async function clickLinkAndVerify(page, route, linkName, expectedPath) {
}; };
} }
async function locateNav(page, label) {
const candidates = [
page.getByRole('link', { name: label }).first(),
page.getByRole('tab', { name: label }).first(),
page.getByRole('button', { name: label }).first(),
];
for (const locator of candidates) {
if ((await locator.count()) > 0) {
return locator;
}
}
return null;
}
async function clickNavAndVerify(page, route, label, expectedPath) {
await navigate(page, route);
const locator = await locateNav(page, label);
if (!locator) {
return {
action: `nav:${label}`,
ok: false,
reason: 'missing-nav',
snapshot: await captureSnapshot(page, `missing-nav:${label}`),
};
}
await locator.click({ timeout: 10_000 });
await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 });
await settle(page);
return {
action: `nav:${label}`,
ok: page.url().includes(expectedPath),
snapshot: await captureSnapshot(page, `after-nav:${label}`),
};
}
async function main() { async function main() {
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
@@ -146,6 +185,12 @@ async function main() {
}); });
const results = []; const results = [];
await navigate(page, '/ops/operations/notifications');
results.push({
action: 'route:/ops/operations/notifications',
ok: (await headingText(page)) === 'Notify control plane',
snapshot: await captureSnapshot(page, 'ops-notifications'),
});
results.push(await clickLinkAndVerify( results.push(await clickLinkAndVerify(
page, page,
'/ops/operations/notifications', '/ops/operations/notifications',
@@ -158,6 +203,24 @@ async function main() {
'Review watchlist alerts', 'Review watchlist alerts',
'/setup/trust-signing/watchlist/alerts', '/setup/trust-signing/watchlist/alerts',
)); ));
await navigate(page, '/setup/notifications');
const setupSnapshot = await captureSnapshot(page, 'setup-notifications');
results.push({
action: 'route:/setup/notifications',
ok:
setupSnapshot.heading === 'Notification Administration' &&
!setupSnapshot.alerts.some((text) => text.toLowerCase().includes('items is not iterable')),
snapshot: setupSnapshot,
});
results.push(await clickNavAndVerify(page, '/setup/notifications', 'Rules', '/setup/notifications/rules'));
results.push(await clickNavAndVerify(page, '/setup/notifications', 'Channels', '/setup/notifications/channels'));
results.push(await clickNavAndVerify(page, '/setup/notifications', 'Delivery', '/setup/notifications/delivery'));
results.push(await clickNavAndVerify(page, '/setup/notifications', 'Simulator', '/setup/notifications/simulator'));
results.push(await clickNavAndVerify(page, '/setup/notifications', 'Config', '/setup/notifications/config'));
results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Quiet Hours', '/setup/notifications/config/quiet-hours'));
results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Overrides', '/setup/notifications/config/overrides'));
results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Escalation', '/setup/notifications/config/escalation'));
results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Throttle', '/setup/notifications/config/throttle'));
const summary = { const summary = {
generatedAtUtc: new Date().toISOString(), generatedAtUtc: new Date().toISOString(),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { StellaOpsHeaders } from '../http/stella-ops-headers';
import { NOTIFIER_API_BASE_URL, NotifierApiHttpClient } from './notifier.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'demo-prod';
}
}
describe('NotifierApiHttpClient', () => {
let httpMock: HttpTestingController;
let client: NotifierApiHttpClient;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
NotifierApiHttpClient,
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: NOTIFIER_API_BASE_URL, useValue: '/api/v1/notifier' },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
client = TestBed.inject(NotifierApiHttpClient);
});
afterEach(() => {
httpMock.verify();
});
it('targets the notifier frontdoor rules collection and normalizes raw array responses', () => {
let result: unknown;
client.listRules({ traceId: 'trace-rules' }).subscribe((response) => {
result = response;
});
const request = httpMock.expectOne('/api/v1/notifier/rules');
expect(request.request.method).toBe('GET');
expect(request.request.headers.get(StellaOpsHeaders.Tenant)).toBe('demo-prod');
expect(request.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-rules');
request.flush([
{
ruleId: 'rule-1',
tenantId: 'demo-prod',
name: 'Critical alerts',
enabled: true,
status: 'active',
match: {},
actions: [],
createdAt: '2026-03-10T00:00:00Z',
},
]);
expect(result).toEqual({
items: [
jasmine.objectContaining({
ruleId: 'rule-1',
}),
],
total: 1,
traceId: 'trace-rules',
});
});
it('normalizes notifier envelope responses for channels', () => {
let result: unknown;
client.listChannels({ traceId: 'trace-channels' }).subscribe((response) => {
result = response;
});
const request = httpMock.expectOne('/api/v1/notifier/channels');
expect(request.request.method).toBe('GET');
request.flush({
items: [
{
channelId: 'chn-1',
tenantId: 'demo-prod',
name: 'Slack alerts',
type: 'Slack',
enabled: true,
config: {},
createdAt: '2026-03-10T00:00:00Z',
},
],
count: 1,
});
expect(result).toEqual({
items: [
jasmine.objectContaining({
channelId: 'chn-1',
}),
],
total: 1,
nextPageToken: undefined,
traceId: 'trace-channels',
});
});
it('uses canonical notifier delivery paths', () => {
client.listDeliveries({ limit: 25 }).subscribe();
const listRequest = httpMock.expectOne((pending) => pending.url === '/api/v1/notifier/deliveries');
expect(listRequest.request.method).toBe('GET');
expect(listRequest.request.params.get('limit')).toBe('25');
listRequest.flush({ items: [], count: 0 });
client.getDeliveryStats({ traceId: 'trace-stats' }).subscribe();
const statsRequest = httpMock.expectOne('/api/v1/notifier/deliveries/stats');
expect(statsRequest.request.method).toBe('GET');
expect(statsRequest.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-stats');
statsRequest.flush({
totalSent: 0,
totalFailed: 0,
totalThrottled: 0,
totalPending: 0,
avgDeliveryTimeMs: 0,
successRate: 100,
period: 'day',
byChannel: {},
byEventKind: {},
});
});
it('uses canonical notifier suppression and escalation paths', () => {
client.listQuietHours().subscribe();
httpMock.expectOne('/api/v1/notifier/quiet-hours').flush({ items: [], count: 0 });
client.listEscalationPolicies().subscribe();
httpMock.expectOne('/api/v1/notifier/escalation-policies').flush({ items: [], count: 0 });
client.listThrottles().subscribe();
httpMock.expectOne('/api/v1/notifier/throttle-configs').flush({ items: [], count: 0 });
});
it('maps simulation calls onto the notifier single-event endpoint', () => {
let result: unknown;
client
.testRule({
ruleId: 'rule-1',
eventKind: 'vulnerability.detected',
eventPayload: { cveId: 'CVE-2026-0001' },
dryRun: true,
})
.subscribe((response) => {
result = response;
});
const request = httpMock.expectOne('/api/v1/notifier/simulate/event');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
eventPayload: { cveId: 'CVE-2026-0001' },
ruleIds: ['rule-1'],
});
request.flush({
simulationId: 'sim-1',
eventResults: [
{
matched: true,
matchedRules: [
{
ruleId: 'rule-1',
actions: [
{
channel: 'chn-1',
template: 'tmpl-1',
},
],
},
],
},
],
});
expect(result).toEqual({
matched: true,
matchedRules: ['rule-1'],
wouldNotify: [
{
channelId: 'chn-1',
channelName: 'chn-1',
templateId: 'tmpl-1',
digestMode: 'instant',
},
],
throttled: false,
throttleReason: undefined,
quietHoursActive: false,
simulationId: 'sim-1',
traceId: jasmine.any(String),
});
});
it('uses notify channel test previews to build admin notification previews', () => {
let result: unknown;
client
.previewNotification({
channelId: 'chn-1',
templateId: 'tmpl-1',
eventKind: 'vulnerability.detected',
eventPayload: { cveId: 'CVE-2026-0001' },
})
.subscribe((response) => {
result = response;
});
const getChannelRequest = httpMock.expectOne('/api/v1/notifier/channels/chn-1');
expect(getChannelRequest.request.method).toBe('GET');
getChannelRequest.flush({
channelId: 'chn-1',
tenantId: 'demo-prod',
name: 'Slack alerts',
type: 'Slack',
enabled: true,
config: {},
createdAt: '2026-03-10T00:00:00Z',
});
const previewRequest = httpMock.expectOne('/api/v1/notify/channels/chn-1/test');
expect(previewRequest.request.method).toBe('POST');
expect(previewRequest.request.body.templateId).toBe('tmpl-1');
previewRequest.flush({
tenantId: 'demo-prod',
channelId: 'chn-1',
queuedAt: '2026-03-10T00:00:00Z',
traceId: 'trace-preview',
preview: {
format: 'Markdown',
title: 'Alert preview',
body: 'Critical vulnerability detected',
},
});
expect(result).toEqual({
channelType: 'Slack',
subject: 'Alert preview',
body: 'Critical vulnerability detected',
htmlBody: undefined,
format: 'markdown',
variables: { cveId: 'CVE-2026-0001' },
previewId: 'trace-preview',
traceId: jasmine.any(String),
});
});
});

View File

@@ -6,10 +6,12 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core'; import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs'; import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map, switchMap } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store'; import { AuthSessionStore } from '../auth/auth-session.store';
import { generateTraceId } from './trace.util'; import { generateTraceId } from './trace.util';
import { StellaOpsHeaders } from '../http/stella-ops-headers';
import { ChannelTestSendResponse } from './notify.models';
import { import {
NotifierRule, NotifierRule,
NotifierRuleRequest, NotifierRuleRequest,
@@ -115,6 +117,60 @@ export interface NotifierApi {
export const NOTIFIER_API = new InjectionToken<NotifierApi>('NOTIFIER_API'); export const NOTIFIER_API = new InjectionToken<NotifierApi>('NOTIFIER_API');
export const NOTIFIER_API_BASE_URL = new InjectionToken<string>('NOTIFIER_API_BASE_URL'); export const NOTIFIER_API_BASE_URL = new InjectionToken<string>('NOTIFIER_API_BASE_URL');
type NotifierCollectionEnvelope<T> =
| readonly T[]
| {
readonly items?: readonly T[];
readonly total?: number;
readonly count?: number;
readonly nextPageToken?: string | null;
readonly continuationToken?: string | null;
readonly traceId?: string;
};
interface NotifierTemplatePreviewEnvelope {
readonly renderedBody?: string;
readonly renderedSubject?: string;
readonly bodyHash?: string;
readonly format?: string;
}
interface NotifierSimulationActionEnvelope {
readonly channelId?: string;
readonly channel?: string;
readonly templateId?: string;
readonly template?: string;
readonly digestMode?: string;
readonly enabled?: boolean;
}
interface NotifierSimulationRuleEnvelope {
readonly ruleId?: string;
readonly matchedAt?: string;
readonly actions?: readonly NotifierSimulationActionEnvelope[];
}
interface NotifierSimulationEventEnvelope {
readonly matched?: boolean;
readonly matchedRules?: readonly NotifierSimulationRuleEnvelope[];
}
interface NotifierSimulationEnvelope {
readonly simulationId?: string;
readonly matched?: boolean;
readonly matchedRules?: readonly string[];
readonly wouldNotify?: readonly {
readonly channelId?: string;
readonly channelName?: string;
readonly templateId?: string;
readonly digestMode?: string;
}[];
readonly throttled?: boolean;
readonly throttleReason?: string;
readonly quietHoursActive?: boolean;
readonly eventResults?: readonly NotifierSimulationEventEnvelope[];
}
/** /**
* HTTP implementation of NotifierApi. * HTTP implementation of NotifierApi.
*/ */
@@ -122,7 +178,8 @@ export const NOTIFIER_API_BASE_URL = new InjectionToken<string>('NOTIFIER_API_BA
export class NotifierApiHttpClient implements NotifierApi { export class NotifierApiHttpClient implements NotifierApi {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore); private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notify'; private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notifier';
private readonly notifyPreviewBaseUrl = '/api/v1/notify';
// ============================================================================ // ============================================================================
// Rules // Rules
@@ -130,11 +187,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listRules(options: NotifierQueryOptions = {}): Observable<NotifierRulesResponse> { listRules(options: NotifierQueryOptions = {}): Observable<NotifierRulesResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierRulesResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierRule>>(
`${this.baseUrl}/rules`, `${this.baseUrl}/rules`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierRule>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -187,11 +244,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listChannels(options: NotifierQueryOptions = {}): Observable<NotifierChannelsResponse> { listChannels(options: NotifierQueryOptions = {}): Observable<NotifierChannelsResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierChannelsResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierChannel>>(
`${this.baseUrl}/channels`, `${this.baseUrl}/channels`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierChannel>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -208,9 +265,10 @@ export class NotifierApiHttpClient implements NotifierApi {
createChannel(request: NotifierChannelRequest, options: NotifierQueryOptions = {}): Observable<NotifierChannel> { createChannel(request: NotifierChannelRequest, options: NotifierQueryOptions = {}): Observable<NotifierChannel> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierChannel>( const channelId = this.createEntityId('chn', request.name);
`${this.baseUrl}/channels`, return this.http.put<NotifierChannel>(
request, `${this.baseUrl}/channels/${encodeURIComponent(channelId)}`,
this.mapChannelUpsertRequest(request),
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -221,7 +279,7 @@ export class NotifierApiHttpClient implements NotifierApi {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.put<NotifierChannel>( return this.http.put<NotifierChannel>(
`${this.baseUrl}/channels/${encodeURIComponent(channelId)}`, `${this.baseUrl}/channels/${encodeURIComponent(channelId)}`,
request, this.mapChannelUpsertRequest(request),
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -240,11 +298,15 @@ export class NotifierApiHttpClient implements NotifierApi {
testChannel(channelId: string, options: NotifierQueryOptions = {}): Observable<{ success: boolean; message: string }> { testChannel(channelId: string, options: NotifierQueryOptions = {}): Observable<{ success: boolean; message: string }> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<{ success: boolean; message: string }>( return this.http.post<ChannelTestSendResponse>(
`${this.baseUrl}/channels/${encodeURIComponent(channelId)}/test`, `${this.notifyPreviewBaseUrl}/channels/${encodeURIComponent(channelId)}/test`,
{}, {},
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
map((response) => ({
success: true,
message: response.preview?.title ?? response.preview?.summary ?? 'Preview generated',
})),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -255,11 +317,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listTemplates(options: NotifierQueryOptions = {}): Observable<NotifierTemplatesResponse> { listTemplates(options: NotifierQueryOptions = {}): Observable<NotifierTemplatesResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierTemplatesResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierTemplate>>(
`${this.baseUrl}/templates`, `${this.baseUrl}/templates`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierTemplate>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -320,13 +382,13 @@ export class NotifierApiHttpClient implements NotifierApi {
if (options.since) params = params.set('since', options.since); if (options.since) params = params.set('since', options.since);
if (options.until) params = params.set('until', options.until); if (options.until) params = params.set('until', options.until);
if (options.limit) params = params.set('limit', String(options.limit)); if (options.limit) params = params.set('limit', String(options.limit));
if (options.offset) params = params.set('offset', String(options.offset)); if (options.pageToken) params = params.set('pageToken', options.pageToken);
return this.http.get<NotifierDeliveryResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierDelivery>>(
`${this.baseUrl}/delivery`, `${this.baseUrl}/deliveries`,
{ headers: this.buildHeaders(traceId), params } { headers: this.buildHeaders(traceId), params }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierDelivery>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -334,7 +396,7 @@ export class NotifierApiHttpClient implements NotifierApi {
getDelivery(deliveryId: string, options: NotifierQueryOptions = {}): Observable<NotifierDelivery> { getDelivery(deliveryId: string, options: NotifierQueryOptions = {}): Observable<NotifierDelivery> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierDelivery>( return this.http.get<NotifierDelivery>(
`${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}`, `${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -344,7 +406,7 @@ export class NotifierApiHttpClient implements NotifierApi {
retryDelivery(deliveryId: string, request?: NotifierRetryRequest, options: NotifierQueryOptions = {}): Observable<NotifierRetryResponse> { retryDelivery(deliveryId: string, request?: NotifierRetryRequest, options: NotifierQueryOptions = {}): Observable<NotifierRetryResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierRetryResponse>( return this.http.post<NotifierRetryResponse>(
`${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}/retry`, `${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}/retry`,
request ?? { deliveryId }, request ?? { deliveryId },
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
@@ -356,7 +418,7 @@ export class NotifierApiHttpClient implements NotifierApi {
getDeliveryStats(options: NotifierQueryOptions = {}): Observable<NotifierDeliveryStats> { getDeliveryStats(options: NotifierQueryOptions = {}): Observable<NotifierDeliveryStats> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierDeliveryStats>( return this.http.get<NotifierDeliveryStats>(
`${this.baseUrl}/delivery/stats`, `${this.baseUrl}/deliveries/stats`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => ({ ...response, traceId })),
@@ -370,24 +432,45 @@ export class NotifierApiHttpClient implements NotifierApi {
testRule(request: NotifierTestRuleRequest, options: NotifierQueryOptions = {}): Observable<NotifierTestRuleResponse> { testRule(request: NotifierTestRuleRequest, options: NotifierQueryOptions = {}): Observable<NotifierTestRuleResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierTestRuleResponse>( return this.http.post<NotifierSimulationEnvelope>(
`${this.baseUrl}/simulation/test`, `${this.baseUrl}/simulate/event`,
request, {
eventPayload: request.eventPayload,
ruleIds: request.ruleId ? [request.ruleId] : [],
},
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeSimulationResponse(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
previewNotification(request: NotifierPreviewRequest, options: NotifierQueryOptions = {}): Observable<NotifierPreviewResponse> { previewNotification(request: NotifierPreviewRequest, options: NotifierQueryOptions = {}): Observable<NotifierPreviewResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierPreviewResponse>( const headers = this.buildHeaders(traceId);
`${this.baseUrl}/simulation/preview`, return this.getChannel(request.channelId, { ...options, traceId }).pipe(
request, switchMap((channel) => {
{ headers: this.buildHeaders(traceId) } const templateId =
).pipe( request.templateId ||
map(response => ({ ...response, traceId })), this.findTemplateIdForPreview(request.ruleId, channel.channelId, request.eventPayload);
if (!templateId) {
return of(this.buildFallbackPreviewResponse(channel.type, request, traceId));
}
return this.http.post<ChannelTestSendResponse>(
`${this.notifyPreviewBaseUrl}/channels/${encodeURIComponent(request.channelId)}/test`,
{
templateId,
body: JSON.stringify(request.eventPayload, null, 2),
title: request.eventKind ?? 'Notification preview',
summary: request.eventKind ?? 'Notification preview',
},
{ headers }
).pipe(
map((response) => this.mapChannelPreviewResponse(channel.type, request, response, traceId)),
);
}),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -398,11 +481,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listQuietHours(options: NotifierQueryOptions = {}): Observable<NotifierQuietHoursResponse> { listQuietHours(options: NotifierQueryOptions = {}): Observable<NotifierQuietHoursResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierQuietHoursResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierQuietHours>>(
`${this.baseUrl}/quiethours`, `${this.baseUrl}/quiet-hours`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierQuietHours>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -410,7 +493,7 @@ export class NotifierApiHttpClient implements NotifierApi {
getQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> { getQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierQuietHours>( return this.http.get<NotifierQuietHours>(
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -419,9 +502,10 @@ export class NotifierApiHttpClient implements NotifierApi {
createQuietHours(request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> { createQuietHours(request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierQuietHours>( const quietHoursId = this.createEntityId('qh', request.name);
`${this.baseUrl}/quiethours`, return this.http.put<NotifierQuietHours>(
request, `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
this.mapQuietHoursUpsertRequest(request),
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -431,8 +515,8 @@ export class NotifierApiHttpClient implements NotifierApi {
updateQuietHours(quietHoursId: string, request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> { updateQuietHours(quietHoursId: string, request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.put<NotifierQuietHours>( return this.http.put<NotifierQuietHours>(
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
request, this.mapQuietHoursUpsertRequest(request),
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -442,7 +526,7 @@ export class NotifierApiHttpClient implements NotifierApi {
deleteQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<void> { deleteQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<void> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.delete<void>( return this.http.delete<void>(
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -455,11 +539,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listOverrides(options: NotifierQueryOptions = {}): Observable<NotifierOverridesResponse> { listOverrides(options: NotifierQueryOptions = {}): Observable<NotifierOverridesResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierOverridesResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierOverride>>(
`${this.baseUrl}/overrides`, `${this.baseUrl}/overrides`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierOverride>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -512,11 +596,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listEscalationPolicies(options: NotifierQueryOptions = {}): Observable<NotifierEscalationPoliciesResponse> { listEscalationPolicies(options: NotifierQueryOptions = {}): Observable<NotifierEscalationPoliciesResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierEscalationPoliciesResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierEscalationPolicy>>(
`${this.baseUrl}/escalation`, `${this.baseUrl}/escalation-policies`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierEscalationPolicy>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -524,7 +608,7 @@ export class NotifierApiHttpClient implements NotifierApi {
getEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> { getEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierEscalationPolicy>( return this.http.get<NotifierEscalationPolicy>(
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -533,8 +617,9 @@ export class NotifierApiHttpClient implements NotifierApi {
createEscalationPolicy(request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> { createEscalationPolicy(request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierEscalationPolicy>( const policyId = this.createEntityId('esc', request.name);
`${this.baseUrl}/escalation`, return this.http.put<NotifierEscalationPolicy>(
`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
request, request,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
@@ -545,7 +630,7 @@ export class NotifierApiHttpClient implements NotifierApi {
updateEscalationPolicy(policyId: string, request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> { updateEscalationPolicy(policyId: string, request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.put<NotifierEscalationPolicy>( return this.http.put<NotifierEscalationPolicy>(
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
request, request,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
@@ -556,7 +641,7 @@ export class NotifierApiHttpClient implements NotifierApi {
deleteEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<void> { deleteEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<void> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.delete<void>( return this.http.delete<void>(
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -569,11 +654,11 @@ export class NotifierApiHttpClient implements NotifierApi {
listThrottles(options: NotifierQueryOptions = {}): Observable<NotifierThrottleResponse> { listThrottles(options: NotifierQueryOptions = {}): Observable<NotifierThrottleResponse> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierThrottleResponse>( return this.http.get<NotifierCollectionEnvelope<NotifierThrottle>>(
`${this.baseUrl}/throttle`, `${this.baseUrl}/throttle-configs`,
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
).pipe( ).pipe(
map(response => ({ ...response, traceId })), map(response => this.normalizeCollectionResponse<NotifierThrottle>(response, traceId)),
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
); );
} }
@@ -581,7 +666,7 @@ export class NotifierApiHttpClient implements NotifierApi {
getThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> { getThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.get<NotifierThrottle>( return this.http.get<NotifierThrottle>(
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -590,8 +675,9 @@ export class NotifierApiHttpClient implements NotifierApi {
createThrottle(request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> { createThrottle(request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.post<NotifierThrottle>( const throttleId = this.createEntityId('thr', request.name);
`${this.baseUrl}/throttle`, return this.http.put<NotifierThrottle>(
`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
request, request,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
@@ -602,7 +688,7 @@ export class NotifierApiHttpClient implements NotifierApi {
updateThrottle(throttleId: string, request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> { updateThrottle(throttleId: string, request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.put<NotifierThrottle>( return this.http.put<NotifierThrottle>(
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
request, request,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
@@ -613,7 +699,7 @@ export class NotifierApiHttpClient implements NotifierApi {
deleteThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<void> { deleteThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<void> {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
return this.http.delete<void>( return this.http.delete<void>(
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
{ headers: this.buildHeaders(traceId) } { headers: this.buildHeaders(traceId) }
).pipe( ).pipe(
catchError(err => throwError(() => this.mapError(err, traceId))) catchError(err => throwError(() => this.mapError(err, traceId)))
@@ -627,9 +713,9 @@ export class NotifierApiHttpClient implements NotifierApi {
private buildHeaders(traceId: string): HttpHeaders { private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || ''; const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({ return new HttpHeaders({
'X-StellaOps-Tenant': tenant, [StellaOpsHeaders.Tenant]: tenant,
'X-Stella-Trace-Id': traceId, [StellaOpsHeaders.TraceId]: traceId,
'X-Stella-Request-Id': traceId, [StellaOpsHeaders.RequestId]: traceId,
'Accept': 'application/json', 'Accept': 'application/json',
}); });
} }
@@ -645,6 +731,172 @@ export class NotifierApiHttpClient implements NotifierApi {
return params; return params;
} }
private normalizeCollectionResponse<T>(
response: NotifierCollectionEnvelope<T>,
traceId: string,
): { items: readonly T[]; total: number; nextPageToken?: string; traceId?: string } {
if (Array.isArray(response)) {
return {
items: response,
total: response.length,
traceId,
};
}
const envelope = response as Exclude<NotifierCollectionEnvelope<T>, readonly T[]>;
const items = Array.isArray(envelope.items) ? envelope.items : [];
const total = typeof envelope.total === 'number'
? envelope.total
: typeof envelope.count === 'number'
? envelope.count
: items.length;
const nextPageToken = envelope.nextPageToken ?? envelope.continuationToken ?? undefined;
return {
items,
total,
nextPageToken: nextPageToken ?? undefined,
traceId: envelope.traceId ?? traceId,
};
}
private normalizeSimulationResponse(response: NotifierSimulationEnvelope, traceId: string): NotifierTestRuleResponse {
if (Array.isArray(response.wouldNotify) && Array.isArray(response.matchedRules)) {
return {
matched: response.matched ?? response.matchedRules.length > 0,
matchedRules: response.matchedRules,
wouldNotify: response.wouldNotify.map((entry) => ({
channelId: entry.channelId ?? 'unknown',
channelName: entry.channelName ?? entry.channelId ?? 'unknown',
templateId: entry.templateId,
digestMode: entry.digestMode ?? 'instant',
})),
throttled: response.throttled ?? false,
throttleReason: response.throttleReason,
quietHoursActive: response.quietHoursActive ?? false,
simulationId: response.simulationId ?? traceId,
traceId,
};
}
const eventResult = response.eventResults?.[0];
const matchedRules = (eventResult?.matchedRules ?? []).map((rule) => rule.ruleId ?? 'unknown');
const wouldNotify = (eventResult?.matchedRules ?? []).flatMap((rule) =>
(rule.actions ?? [])
.filter((action) => action.enabled !== false)
.map((action) => ({
channelId: action.channelId ?? action.channel ?? 'unknown',
channelName: action.channel ?? action.channelId ?? 'unknown',
templateId: action.templateId ?? action.template,
digestMode: action.digestMode ?? 'instant',
})),
);
return {
matched: eventResult?.matched ?? matchedRules.length > 0,
matchedRules,
wouldNotify,
throttled: response.throttled ?? false,
throttleReason: response.throttleReason,
quietHoursActive: response.quietHoursActive ?? false,
simulationId: response.simulationId ?? traceId,
traceId,
};
}
private mapChannelPreviewResponse(
channelType: NotifierChannel['type'],
request: NotifierPreviewRequest,
response: ChannelTestSendResponse,
traceId: string,
): NotifierPreviewResponse {
const format = this.normalizePreviewFormat(response.preview?.format);
return {
channelType,
subject: response.preview?.title ?? response.preview?.summary,
body: response.preview?.body ?? JSON.stringify(request.eventPayload, null, 2),
format,
variables: request.eventPayload,
previewId: response.traceId || traceId,
traceId,
};
}
private buildFallbackPreviewResponse(
channelType: NotifierChannel['type'],
request: NotifierPreviewRequest,
traceId: string,
): NotifierPreviewResponse {
return {
channelType,
subject: request.eventKind ?? 'Notification preview',
body: JSON.stringify(request.eventPayload, null, 2),
format: 'text',
variables: request.eventPayload,
previewId: traceId,
traceId,
};
}
private normalizePreviewFormat(format: string | undefined): 'text' | 'markdown' | 'html' {
const normalized = format?.toLowerCase();
if (normalized === 'html') {
return 'html';
}
if (normalized === 'markdown') {
return 'markdown';
}
return 'text';
}
private mapChannelUpsertRequest(request: NotifierChannelRequest): Record<string, unknown> {
const config = request.config as Record<string, unknown>;
return {
name: request.name,
description: request.description,
type: request.type,
secretRef: typeof config['secretRef'] === 'string' ? config['secretRef'] : undefined,
endpoint:
(typeof config['webhookUrl'] === 'string' && config['webhookUrl']) ||
(typeof config['url'] === 'string' && config['url']) ||
undefined,
target:
(typeof config['channel'] === 'string' && config['channel']) ||
(Array.isArray(config['toAddresses']) ? config['toAddresses'].join(', ') : undefined) ||
(typeof config['routingKey'] === 'string' && config['routingKey']) ||
undefined,
};
}
private mapQuietHoursUpsertRequest(request: NotifierQuietHoursRequest): Record<string, unknown> {
return {
name: request.name,
description: request.description,
windows: request.windows,
exemptions: request.exemptions,
enabled: request.enabled,
};
}
private createEntityId(prefix: string, name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
const entropy = generateTraceId().replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(-8);
return `${prefix}-${slug || 'item'}-${entropy}`;
}
private findTemplateIdForPreview(
_ruleId: string | undefined,
_channelId: string,
_eventPayload: Record<string, unknown>,
): string | undefined {
return undefined;
}
private mapError(err: unknown, traceId: string): Error { private mapError(err: unknown, traceId: string): Error {
return err instanceof Error return err instanceof Error
? new Error(`[${traceId}] Notifier error: ${err.message}`) ? new Error(`[${traceId}] Notifier error: ${err.message}`)

View File

@@ -339,8 +339,10 @@ export interface NotifierTestRuleResponse {
/** Preview notification request */ /** Preview notification request */
export interface NotifierPreviewRequest { export interface NotifierPreviewRequest {
readonly templateId?: string; readonly templateId?: string;
readonly ruleId?: string;
readonly channelId: string; readonly channelId: string;
readonly eventPayload: Record<string, unknown>; readonly eventPayload: Record<string, unknown>;
readonly eventKind?: string;
readonly locale?: string; readonly locale?: string;
} }

View File

@@ -588,7 +588,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{ {
id: 'admin-notifications', id: 'admin-notifications',
label: 'Notification Admin', label: 'Notification Admin',
route: '/ops/operations/notifications', route: '/setup/notifications',
icon: 'bell-config', icon: 'bell-config',
tooltip: 'Configure notification rules, channels, and templates', tooltip: 'Configure notification rules, channels, and templates',
}, },

View File

@@ -726,9 +726,16 @@ export class RuleSimulatorComponent implements OnInit {
this.previewResult.set(null); this.previewResult.set(null);
try { try {
const selectedRule = this.rules().find((rule) => rule.ruleId === this.selectedRuleId());
const selectedAction =
selectedRule?.actions.find((action) => action.channelId === this.previewChannelId) ??
selectedRule?.actions[0];
const result = await firstValueFrom(this.api.previewNotification({ const result = await firstValueFrom(this.api.previewNotification({
ruleId: this.selectedRuleId() || undefined,
templateId: selectedAction?.templateId,
channelId: this.previewChannelId, channelId: this.previewChannelId,
eventPayload: payload, eventPayload: payload,
eventKind: this.eventKind || undefined,
})); }));
this.previewResult.set(result); this.previewResult.set(result);

View File

@@ -191,8 +191,8 @@ export const OPERATIONS_ROUTES: Routes = [
path: 'notifications', path: 'notifications',
title: 'Notifications', title: 'Notifications',
data: { breadcrumb: 'Notifications' }, data: { breadcrumb: 'Notifications' },
loadChildren: () => loadComponent: () =>
import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes), import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent),
}, },
{ {
path: 'environments', path: 'environments',

View File

@@ -49,32 +49,19 @@ describe('Route surface ownership', () => {
expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments'); expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments');
}); });
it('preserves setup notifications redirects into Operations notifications', () => { it('mounts setup notifications as the admin studio surface', () => {
const notificationsRoute = SETUP_ROUTES.find((route) => route.path === 'notifications'); const notificationsRoute = SETUP_ROUTES.find((route) => route.path === 'notifications');
expect(notificationsRoute?.pathMatch).toBe('prefix'); expect(typeof notificationsRoute?.loadChildren).toBe('function');
expect(typeof notificationsRoute?.redirectTo).toBe('function'); expect(notificationsRoute?.title).toBe('Notifications');
const notificationsRedirect = notificationsRoute?.redirectTo;
if (typeof notificationsRedirect !== 'function') {
throw new Error('Setup notifications route must expose a redirect function.');
}
const target = invokeRedirect(notificationsRedirect, {
params: {},
queryParams: { tenant: 'demo-prod', regions: 'us-east' },
fragment: 'channels',
});
expect(target).toBe('/ops/operations/notifications?tenant=demo-prod&regions=us-east#channels');
}); });
it('mounts Operations ownership for notifications and environments', () => { it('mounts Operations ownership for operator notifications and environments', () => {
const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications'); const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications');
const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments'); const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments');
const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId'); const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId');
expect(typeof notificationsRoute?.loadChildren).toBe('function'); expect(typeof notificationsRoute?.loadComponent).toBe('function');
expect(typeof environmentsRoute?.loadComponent).toBe('function'); expect(typeof environmentsRoute?.loadComponent).toBe('function');
expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
}); });

View File

@@ -1,5 +1,4 @@
import { inject } from '@angular/core'; import { Routes } from '@angular/router';
import { Router, Routes } from '@angular/router';
export const SETUP_ROUTES: Routes = [ export const SETUP_ROUTES: Routes = [
{ {
@@ -27,17 +26,12 @@ export const SETUP_ROUTES: Routes = [
(m) => m.BrandingSettingsPageComponent, (m) => m.BrandingSettingsPageComponent,
), ),
}, },
// Redirect to consolidated notifications under Operations
{ {
path: 'notifications', path: 'notifications',
pathMatch: 'prefix', title: 'Notifications',
redirectTo: ({ queryParams, fragment }) => { data: { breadcrumb: 'Notifications' },
const router = inject(Router); loadChildren: () =>
const target = router.parseUrl('/ops/operations/notifications'); import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
target.queryParams = { ...queryParams };
target.fragment = fragment ?? null;
return target;
},
}, },
{ {
path: 'usage', path: 'usage',