From df795119e2b886b5b82862d40523878dd8dd143b Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:44:56 +0200 Subject: [PATCH] cbore: extend component impact (#2314) * cbore: extend component impact * fix: remove unused changed prop * fix: review --- apps/web/next-env.d.ts | 2 +- .../src/channels/webhook.test.ts | 70 +++++++++++++++++-- .../subscriptions/src/channels/webhook.ts | 60 ++++++++++------ packages/subscriptions/src/dispatcher.ts | 21 ++++++ packages/subscriptions/src/payload.ts | 68 ++++++++++++++++++ packages/subscriptions/src/types.ts | 24 ++++--- 6 files changed, 209 insertions(+), 36 deletions(-) create mode 100644 packages/subscriptions/src/payload.ts diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/subscriptions/src/channels/webhook.test.ts b/packages/subscriptions/src/channels/webhook.test.ts index acb94676..640e933d 100644 --- a/packages/subscriptions/src/channels/webhook.test.ts +++ b/packages/subscriptions/src/channels/webhook.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { webhookPayloadSchema } from "../payload"; import type { PageUpdate, Subscription } from "../types"; import { buildGenericPayload, @@ -306,15 +307,24 @@ describe("buildGenericPayload", () => { message: "Looking into it.", date: "2026-04-21T09:59:58Z", updateId: 42, - pageComponentsWithId: [{ id: 7, name: "API" }], + componentsWithImpact: [ + { id: 7, name: "API", impact: "major_outage" }, + { + id: 8, + name: "Dashboard", + impact: "degraded_performance", + }, + ], }); const payload = buildGenericPayload(update, sub, links) as { + version: string; type: string; data: { status_report: Record }; subscription: { manage_url: string; unsubscribe_url: string }; }; + expect(payload.version).toBe("1"); expect(payload.type).toBe("status_report"); expect(payload.data.status_report).toMatchObject({ id: 12, @@ -325,8 +335,20 @@ describe("buildGenericPayload", () => { message: "Looking into it.", created_at: "2026-04-21T09:59:58Z", }, - page: { id: 42, name: "Acme", slug: "acme" }, - components: [{ id: 7, name: "API" }], + page: { + id: 42, + name: "Acme", + slug: "acme", + url: "https://acme.openstatus.dev", + }, + components: [ + { id: 7, name: "API", impact: "major_outage" }, + { + id: 8, + name: "Dashboard", + impact: "degraded_performance", + }, + ], }); expect(payload.subscription).toEqual({ manage_url: links.manageUrl, @@ -347,10 +369,12 @@ describe("buildGenericPayload", () => { }); const payload = buildGenericPayload(update, sub, links) as { + version: string; type: string; data: { maintenance: Record }; }; + expect(payload.version).toBe("1"); expect(payload.type).toBe("maintenance"); expect(payload.data.maintenance).toMatchObject({ id: 17, @@ -358,11 +382,47 @@ describe("buildGenericPayload", () => { message: "Rolling primary.", starts_at: "2026-04-22T02:00:00Z", ends_at: "2026-04-22T03:00:00Z", - page: { id: 42, name: "Acme", slug: "acme" }, - components: [{ id: 7, name: "API" }], + page: { + id: 42, + name: "Acme", + slug: "acme", + url: "https://acme.openstatus.dev", + }, + // maintenance has no per-component impact: falls back to operational + components: [{ id: 7, name: "API", impact: "operational" }], }); expect(payload.data.maintenance).not.toHaveProperty("status"); }); + + test("output satisfies the canonical webhookPayloadSchema contract", () => { + const sub = makeSub({ pageId: 42, pageName: "Acme", pageSlug: "acme" }); + const report = buildGenericPayload( + makeUpdate({ + id: 12, + status: "investigating", + updateId: 42, + componentsWithImpact: [ + { id: 7, name: "API", impact: "partial_outage" }, + ], + }), + sub, + links, + ); + const maintenance = buildGenericPayload( + makeUpdate({ + id: 17, + status: "maintenance", + startsAt: "2026-04-22T02:00:00Z", + endsAt: "2026-04-22T03:00:00Z", + pageComponentsWithId: [{ id: 7, name: "API" }], + }), + sub, + links, + ); + + expect(webhookPayloadSchema.safeParse(report).success).toBe(true); + expect(webhookPayloadSchema.safeParse(maintenance).success).toBe(true); + }); }); // ─── buildTestPayload ───────────────────────────────────────────────────────── diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts index 25b09e1c..94d0402f 100644 --- a/packages/subscriptions/src/channels/webhook.ts +++ b/packages/subscriptions/src/channels/webhook.ts @@ -2,12 +2,14 @@ import { COLORS, COLOR_DECIMALS } from "@openstatus/notification-base"; import { assertSafeUrl } from "@openstatus/utils"; import { z } from "zod"; +import { WEBHOOK_PAYLOAD_VERSION } from "../payload"; import type { PageUpdate, Subscription } from "../types"; export type WebhookFlavor = "slack" | "discord" | "generic"; const SLACK_PREFIX = "https://hooks.slack.com/services/"; const DISCORD_PREFIX = "https://discord.com/api/webhooks/"; +const TIMEOUT_MS = 5000; // 5 seconds /** * Classify a webhook URL by its incoming-webhook origin so we can emit @@ -60,19 +62,20 @@ function statusColor(status: PageUpdate["status"]): StatusColor { } } +const webhookConfigSchema = z.object({ + headers: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), + ) + .optional(), + secret: z.string().optional(), +}); + export async function validateWebhookConfig(config: unknown) { - const schema = z.object({ - headers: z - .array( - z.object({ - key: z.string().min(1), - value: z.string(), - }), - ) - .optional(), - secret: z.string().optional(), - }); - const result = schema.safeParse(config); + const result = webhookConfigSchema.safeParse(config); return { valid: result.success, error: result.error?.message }; } @@ -99,7 +102,7 @@ export async function sendWebhookVerification( token: subscription.token, verifyUrl, }), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!response.ok) { @@ -255,10 +258,19 @@ export function buildGenericPayload( id: subscription.pageId, name: subscription.pageName, slug: subscription.pageSlug, + url: resolveStatusPageOrigin(subscription), }; const components = - pageUpdate.pageComponentsWithId ?? - pageUpdate.pageComponents.map((name, i) => ({ id: i, name })); + pageUpdate.componentsWithImpact ?? + pageUpdate.pageComponentsWithId?.map((c) => ({ + ...c, + impact: "operational" as const, + })) ?? + pageUpdate.pageComponents.map((name, i) => ({ + id: i, // synthetic id: legacy bare-names path has no real pageComponentId + name, + impact: "operational" as const, + })); const subscriptionBlock = { manage_url: links.manageUrl, unsubscribe_url: links.unsubscribeUrl, @@ -266,6 +278,7 @@ export function buildGenericPayload( if (pageUpdate.status === "maintenance") { return { + version: WEBHOOK_PAYLOAD_VERSION, type: "maintenance" as const, data: { maintenance: { @@ -282,7 +295,12 @@ export function buildGenericPayload( }; } + if (pageUpdate.updateId == null) { + throw new Error("status_report webhook payload requires updateId"); + } + return { + version: WEBHOOK_PAYLOAD_VERSION, type: "status_report" as const, data: { status_report: { @@ -360,11 +378,9 @@ export async function sendWebhookNotifications( "User-Agent": "OpenStatus-Webhooks/1.0", }; - if (config.headers) { - for (const header of config.headers as { - key: string; - value: string; - }[]) { + const parsedConfig = webhookConfigSchema.safeParse(config); + if (parsedConfig.success) { + for (const header of parsedConfig.data.headers ?? []) { headers[header.key] = header.value; } } @@ -375,7 +391,7 @@ export async function sendWebhookNotifications( method: "POST", headers, body: JSON.stringify(payload), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!response.ok) { @@ -468,7 +484,7 @@ export async function sendTestWebhookRequest(input: { method: "POST", headers, body: JSON.stringify(buildTestPayload(flavor)), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!response.ok) { diff --git a/packages/subscriptions/src/dispatcher.ts b/packages/subscriptions/src/dispatcher.ts index f06aab70..08e51301 100644 --- a/packages/subscriptions/src/dispatcher.ts +++ b/packages/subscriptions/src/dispatcher.ts @@ -5,6 +5,7 @@ import { pageSubscriber, statusReportUpdate, } from "@openstatus/db/src/schema"; +import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; import { getChannel } from "./channels"; import type { PageUpdate, Subscription } from "./types"; @@ -18,9 +19,15 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) { with: { statusReport: { with: { + // Membership: the full set of components on the report (id + name). statusReportsToPageComponents: { with: { pageComponent: true }, }, + // All updates' impact rows — current state is reconstructed from the + // delta history, not from any single update's rows. + statusReportUpdates: { + with: { statusReportUpdateToPageComponents: true }, + }, }, }, }, @@ -40,6 +47,19 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) { (i) => i.pageComponent, ); + const currentImpacts = currentImpactsFromUpdates( + update.statusReport.statusReportUpdates.map((u) => ({ + id: u.id, + date: u.date, + componentImpacts: u.statusReportUpdateToPageComponents, + })), + ); + const componentsWithImpact = pageComponents.map((c) => ({ + id: c.id, + name: c.name, + impact: currentImpacts.get(c.id) ?? "operational", + })); + await dispatchPageUpdate({ id: update.statusReport.id, pageId: update.statusReport.pageId, @@ -54,6 +74,7 @@ export async function dispatchStatusReportUpdate(statusReportUpdateId: number) { id: c.id, name: c.name, })), + componentsWithImpact, }); } diff --git a/packages/subscriptions/src/payload.ts b/packages/subscriptions/src/payload.ts new file mode 100644 index 00000000..aeba254e --- /dev/null +++ b/packages/subscriptions/src/payload.ts @@ -0,0 +1,68 @@ +import { pageComponentImpactSchema } from "@openstatus/db/src/schema/page_components/validation"; +import { statusReportStatusSchema } from "@openstatus/db/src/schema/status_reports/validation"; +import { z } from "zod"; + +/** Bump on any breaking change — external consumers (statuspage-socials-notifier) pin to this version. */ +export const WEBHOOK_PAYLOAD_VERSION = "1" as const; + +const componentSchema = z.object({ + id: z.number().int(), + name: z.string(), + impact: pageComponentImpactSchema, +}); + +const pageSchema = z.object({ + id: z.number().int(), + name: z.string(), + slug: z.string(), + url: z.url(), +}); + +const subscriptionSchema = z.object({ + manage_url: z.string().nullable(), + unsubscribe_url: z.string().nullable(), +}); + +export const statusReportWebhookSchema = z.object({ + version: z.literal(WEBHOOK_PAYLOAD_VERSION), + type: z.literal("status_report"), + data: z.object({ + status_report: z.object({ + id: z.number().int(), + title: z.string(), + update: z.object({ + id: z.number().int(), + status: statusReportStatusSchema, + message: z.string(), + created_at: z.string(), + }), + page: pageSchema, + components: z.array(componentSchema), + }), + }), + subscription: subscriptionSchema, +}); + +export const maintenanceWebhookSchema = z.object({ + version: z.literal(WEBHOOK_PAYLOAD_VERSION), + type: z.literal("maintenance"), + data: z.object({ + maintenance: z.object({ + id: z.number().int(), + title: z.string(), + message: z.string(), + starts_at: z.string().optional(), + ends_at: z.string().optional(), + page: pageSchema, + components: z.array(componentSchema), + }), + }), + subscription: subscriptionSchema, +}); + +export const webhookPayloadSchema = z.discriminatedUnion("type", [ + statusReportWebhookSchema, + maintenanceWebhookSchema, +]); + +export type WebhookPayload = z.infer; diff --git a/packages/subscriptions/src/types.ts b/packages/subscriptions/src/types.ts index 908f0fca..f0c95d7c 100644 --- a/packages/subscriptions/src/types.ts +++ b/packages/subscriptions/src/types.ts @@ -1,17 +1,19 @@ // Core types for the subscription system +import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; + export interface Subscription { id: number; pageId: number; - pageName: string; // For templates - pageSlug: string; // For management URLs - customDomain?: string | null; // For custom domain URLs + pageName: string; + pageSlug: string; + customDomain?: string | null; componentIds: number[]; // Empty = entire page - // Channel (only ONE identifier populated based on channelType) + // Only ONE identifier populated based on channelType channelType: "email" | "webhook"; - email?: string; // For email channel - webhookUrl?: string; // For webhook channel + email?: string; + webhookUrl?: string; channelConfig?: string; // JSON string for headers, secrets, etc. token?: string; @@ -34,8 +36,8 @@ export interface PageUpdate { | "resolved" | "maintenance"; message: string; - pageComponentIds: number[]; // For subscription matching - pageComponents: string[]; // Component names for display + pageComponentIds: number[]; + pageComponents: string[]; date: string; // can be single string or "from - to" // Optional fields consumed by the prepared (staged) generic webhook payload. @@ -43,6 +45,12 @@ export interface PageUpdate { // builders, which key off the fields above. updateId?: number; // statusReportUpdate.id (status reports only) pageComponentsWithId?: { id: number; name: string }[]; + // Current impact per component as of this update (not the raw delta). + componentsWithImpact?: { + id: number; + name: string; + impact: PageComponentImpact; + }[]; startsAt?: string; // maintenance only, ISO endsAt?: string; // maintenance only, ISO } -- 2.51.2