Fix notifications surface ownership and frontdoor contracts
This commit is contained in:
@@ -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/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/(.*)", "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/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" },
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,7 @@
|
||||
> **Scope.** Implementation‑ready architecture for **Notify** (aligned with Epic 11 – Notifications Studio): a rules‑driven, tenant‑aware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operator‑defined routing rules, renders **channel‑specific messages** (Slack/Teams/Email/Webhook), and delivers them **reliably** with idempotency, throttling, and digests. It is UI‑managed, 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
|
||||
|
||||
@@ -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/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/(.*)", "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/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" },
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
("^/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/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/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),
|
||||
|
||||
@@ -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() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
@@ -146,6 +185,12 @@ async function main() {
|
||||
});
|
||||
|
||||
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(
|
||||
page,
|
||||
'/ops/operations/notifications',
|
||||
@@ -158,6 +203,24 @@ async function main() {
|
||||
'Review 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 = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
251
src/Web/StellaOps.Web/src/app/core/api/notifier.client.spec.ts
Normal file
251
src/Web/StellaOps.Web/src/app/core/api/notifier.client.spec.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
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 { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { ChannelTestSendResponse } from './notify.models';
|
||||
import {
|
||||
NotifierRule,
|
||||
NotifierRuleRequest,
|
||||
@@ -115,6 +117,60 @@ export interface NotifierApi {
|
||||
export const NOTIFIER_API = new InjectionToken<NotifierApi>('NOTIFIER_API');
|
||||
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.
|
||||
*/
|
||||
@@ -122,7 +178,8 @@ export const NOTIFIER_API_BASE_URL = new InjectionToken<string>('NOTIFIER_API_BA
|
||||
export class NotifierApiHttpClient implements NotifierApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
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
|
||||
@@ -130,11 +187,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listRules(options: NotifierQueryOptions = {}): Observable<NotifierRulesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierRulesResponse>(
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierRule>>(
|
||||
`${this.baseUrl}/rules`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierRule>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -187,11 +244,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listChannels(options: NotifierQueryOptions = {}): Observable<NotifierChannelsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierChannelsResponse>(
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierChannel>>(
|
||||
`${this.baseUrl}/channels`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierChannel>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -208,9 +265,10 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
createChannel(request: NotifierChannelRequest, options: NotifierQueryOptions = {}): Observable<NotifierChannel> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierChannel>(
|
||||
`${this.baseUrl}/channels`,
|
||||
request,
|
||||
const channelId = this.createEntityId('chn', request.name);
|
||||
return this.http.put<NotifierChannel>(
|
||||
`${this.baseUrl}/channels/${encodeURIComponent(channelId)}`,
|
||||
this.mapChannelUpsertRequest(request),
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -221,7 +279,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.put<NotifierChannel>(
|
||||
`${this.baseUrl}/channels/${encodeURIComponent(channelId)}`,
|
||||
request,
|
||||
this.mapChannelUpsertRequest(request),
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
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 }> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/channels/${encodeURIComponent(channelId)}/test`,
|
||||
return this.http.post<ChannelTestSendResponse>(
|
||||
`${this.notifyPreviewBaseUrl}/channels/${encodeURIComponent(channelId)}/test`,
|
||||
{},
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
map((response) => ({
|
||||
success: true,
|
||||
message: response.preview?.title ?? response.preview?.summary ?? 'Preview generated',
|
||||
})),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -255,11 +317,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listTemplates(options: NotifierQueryOptions = {}): Observable<NotifierTemplatesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierTemplatesResponse>(
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierTemplate>>(
|
||||
`${this.baseUrl}/templates`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierTemplate>(response, 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.until) params = params.set('until', options.until);
|
||||
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>(
|
||||
`${this.baseUrl}/delivery`,
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierDelivery>>(
|
||||
`${this.baseUrl}/deliveries`,
|
||||
{ headers: this.buildHeaders(traceId), params }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierDelivery>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -334,7 +396,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
getDelivery(deliveryId: string, options: NotifierQueryOptions = {}): Observable<NotifierDelivery> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierDelivery>(
|
||||
`${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}`,
|
||||
`${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
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> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierRetryResponse>(
|
||||
`${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}/retry`,
|
||||
`${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}/retry`,
|
||||
request ?? { deliveryId },
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
@@ -356,7 +418,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
getDeliveryStats(options: NotifierQueryOptions = {}): Observable<NotifierDeliveryStats> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierDeliveryStats>(
|
||||
`${this.baseUrl}/delivery/stats`,
|
||||
`${this.baseUrl}/deliveries/stats`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
@@ -370,24 +432,45 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
testRule(request: NotifierTestRuleRequest, options: NotifierQueryOptions = {}): Observable<NotifierTestRuleResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierTestRuleResponse>(
|
||||
`${this.baseUrl}/simulation/test`,
|
||||
request,
|
||||
return this.http.post<NotifierSimulationEnvelope>(
|
||||
`${this.baseUrl}/simulate/event`,
|
||||
{
|
||||
eventPayload: request.eventPayload,
|
||||
ruleIds: request.ruleId ? [request.ruleId] : [],
|
||||
},
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeSimulationResponse(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
previewNotification(request: NotifierPreviewRequest, options: NotifierQueryOptions = {}): Observable<NotifierPreviewResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierPreviewResponse>(
|
||||
`${this.baseUrl}/simulation/preview`,
|
||||
request,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
const headers = this.buildHeaders(traceId);
|
||||
return this.getChannel(request.channelId, { ...options, traceId }).pipe(
|
||||
switchMap((channel) => {
|
||||
const templateId =
|
||||
request.templateId ||
|
||||
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)))
|
||||
);
|
||||
}
|
||||
@@ -398,11 +481,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listQuietHours(options: NotifierQueryOptions = {}): Observable<NotifierQuietHoursResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierQuietHoursResponse>(
|
||||
`${this.baseUrl}/quiethours`,
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierQuietHours>>(
|
||||
`${this.baseUrl}/quiet-hours`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierQuietHours>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -410,7 +493,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
getQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierQuietHours>(
|
||||
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`,
|
||||
`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -419,9 +502,10 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
createQuietHours(request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable<NotifierQuietHours> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierQuietHours>(
|
||||
`${this.baseUrl}/quiethours`,
|
||||
request,
|
||||
const quietHoursId = this.createEntityId('qh', request.name);
|
||||
return this.http.put<NotifierQuietHours>(
|
||||
`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
|
||||
this.mapQuietHoursUpsertRequest(request),
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
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> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.put<NotifierQuietHours>(
|
||||
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`,
|
||||
request,
|
||||
`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
|
||||
this.mapQuietHoursUpsertRequest(request),
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -442,7 +526,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
deleteQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable<void> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`,
|
||||
`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -455,11 +539,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listOverrides(options: NotifierQueryOptions = {}): Observable<NotifierOverridesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierOverridesResponse>(
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierOverride>>(
|
||||
`${this.baseUrl}/overrides`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierOverride>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -512,11 +596,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listEscalationPolicies(options: NotifierQueryOptions = {}): Observable<NotifierEscalationPoliciesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierEscalationPoliciesResponse>(
|
||||
`${this.baseUrl}/escalation`,
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierEscalationPolicy>>(
|
||||
`${this.baseUrl}/escalation-policies`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierEscalationPolicy>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -524,7 +608,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
getEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierEscalationPolicy>(
|
||||
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`,
|
||||
`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -533,8 +617,9 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
createEscalationPolicy(request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierEscalationPolicy>(
|
||||
`${this.baseUrl}/escalation`,
|
||||
const policyId = this.createEntityId('esc', request.name);
|
||||
return this.http.put<NotifierEscalationPolicy>(
|
||||
`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
|
||||
request,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
@@ -545,7 +630,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
updateEscalationPolicy(policyId: string, request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable<NotifierEscalationPolicy> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.put<NotifierEscalationPolicy>(
|
||||
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`,
|
||||
`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
|
||||
request,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
@@ -556,7 +641,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
deleteEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable<void> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`,
|
||||
`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -569,11 +654,11 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
listThrottles(options: NotifierQueryOptions = {}): Observable<NotifierThrottleResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierThrottleResponse>(
|
||||
`${this.baseUrl}/throttle`,
|
||||
return this.http.get<NotifierCollectionEnvelope<NotifierThrottle>>(
|
||||
`${this.baseUrl}/throttle-configs`,
|
||||
{ headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) }
|
||||
).pipe(
|
||||
map(response => ({ ...response, traceId })),
|
||||
map(response => this.normalizeCollectionResponse<NotifierThrottle>(response, traceId)),
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
@@ -581,7 +666,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
getThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.get<NotifierThrottle>(
|
||||
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`,
|
||||
`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -590,8 +675,9 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
|
||||
createThrottle(request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.post<NotifierThrottle>(
|
||||
`${this.baseUrl}/throttle`,
|
||||
const throttleId = this.createEntityId('thr', request.name);
|
||||
return this.http.put<NotifierThrottle>(
|
||||
`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
|
||||
request,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
@@ -602,7 +688,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
updateThrottle(throttleId: string, request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable<NotifierThrottle> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.put<NotifierThrottle>(
|
||||
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`,
|
||||
`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
|
||||
request,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
@@ -613,7 +699,7 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
deleteThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable<void> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`,
|
||||
`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`,
|
||||
{ headers: this.buildHeaders(traceId) }
|
||||
).pipe(
|
||||
catchError(err => throwError(() => this.mapError(err, traceId)))
|
||||
@@ -627,9 +713,9 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
}
|
||||
@@ -645,6 +731,172 @@ export class NotifierApiHttpClient implements NotifierApi {
|
||||
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 {
|
||||
return err instanceof Error
|
||||
? new Error(`[${traceId}] Notifier error: ${err.message}`)
|
||||
|
||||
@@ -339,8 +339,10 @@ export interface NotifierTestRuleResponse {
|
||||
/** Preview notification request */
|
||||
export interface NotifierPreviewRequest {
|
||||
readonly templateId?: string;
|
||||
readonly ruleId?: string;
|
||||
readonly channelId: string;
|
||||
readonly eventPayload: Record<string, unknown>;
|
||||
readonly eventKind?: string;
|
||||
readonly locale?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'admin-notifications',
|
||||
label: 'Notification Admin',
|
||||
route: '/ops/operations/notifications',
|
||||
route: '/setup/notifications',
|
||||
icon: 'bell-config',
|
||||
tooltip: 'Configure notification rules, channels, and templates',
|
||||
},
|
||||
|
||||
@@ -726,9 +726,16 @@ export class RuleSimulatorComponent implements OnInit {
|
||||
this.previewResult.set(null);
|
||||
|
||||
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({
|
||||
ruleId: this.selectedRuleId() || undefined,
|
||||
templateId: selectedAction?.templateId,
|
||||
channelId: this.previewChannelId,
|
||||
eventPayload: payload,
|
||||
eventKind: this.eventKind || undefined,
|
||||
}));
|
||||
|
||||
this.previewResult.set(result);
|
||||
|
||||
@@ -191,8 +191,8 @@ export const OPERATIONS_ROUTES: Routes = [
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
loadChildren: () =>
|
||||
import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
|
||||
loadComponent: () =>
|
||||
import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent),
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
|
||||
@@ -49,32 +49,19 @@ describe('Route surface ownership', () => {
|
||||
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');
|
||||
|
||||
expect(notificationsRoute?.pathMatch).toBe('prefix');
|
||||
expect(typeof notificationsRoute?.redirectTo).toBe('function');
|
||||
|
||||
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®ions=us-east#channels');
|
||||
expect(typeof notificationsRoute?.loadChildren).toBe('function');
|
||||
expect(notificationsRoute?.title).toBe('Notifications');
|
||||
});
|
||||
|
||||
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 environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments');
|
||||
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 environmentDetailRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const SETUP_ROUTES: Routes = [
|
||||
{
|
||||
@@ -27,17 +26,12 @@ export const SETUP_ROUTES: Routes = [
|
||||
(m) => m.BrandingSettingsPageComponent,
|
||||
),
|
||||
},
|
||||
// Redirect to consolidated notifications under Operations
|
||||
{
|
||||
path: 'notifications',
|
||||
pathMatch: 'prefix',
|
||||
redirectTo: ({ queryParams, fragment }) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl('/ops/operations/notifications');
|
||||
target.queryParams = { ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
},
|
||||
title: 'Notifications',
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
loadChildren: () =>
|
||||
import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
|
||||
Reference in New Issue
Block a user