diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 81ac0e6c8..5526ea262 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -7,7 +7,6 @@ import { HazelAlertDestinationConfig, HazelOAuthAlertDestinationConfig, PagerDutyAlertDestinationConfig, - SlackAlertDestinationConfig, SlackBotAlertDestinationConfig, WebhookAlertDestinationConfig, } from "@maple/domain/http" @@ -44,14 +43,6 @@ const toV2DestinationMutation = (doc: AlertDestinationDocument): V2AlertDestinat const toCreateRequest = (params: V2AlertDestinationCreateParams) => { switch (params.type) { - case "slack": - return new SlackAlertDestinationConfig({ - type: "slack", - name: params.name, - webhookUrl: params.webhook_url, - ...(params.channel_label !== undefined ? { channelLabel: params.channel_label } : {}), - ...(params.enabled !== undefined ? { enabled: params.enabled } : {}), - }) case "slack-bot": return new SlackBotAlertDestinationConfig({ type: "slack-bot", @@ -119,13 +110,6 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati ...(params.enabled !== undefined ? { enabled: params.enabled } : {}), } switch (params.type) { - case "slack": - return { - type: "slack", - ...shared, - ...(params.webhook_url !== undefined ? { webhookUrl: params.webhook_url } : {}), - ...(params.channel_label !== undefined ? { channelLabel: params.channel_label } : {}), - } case "slack-bot": return { type: "slack-bot", diff --git a/apps/api/src/services/AlertDeliveryDispatch.ts b/apps/api/src/services/AlertDeliveryDispatch.ts index 33621faa2..a93e51789 100644 --- a/apps/api/src/services/AlertDeliveryDispatch.ts +++ b/apps/api/src/services/AlertDeliveryDispatch.ts @@ -595,13 +595,22 @@ export const verifyPagerDutyRoutingKey = ( * formatter) or when rendering fails for any reason — templating must never * block delivery. */ +/** + * The key a rule's notification-template `overrides` map is keyed by. It mostly + * mirrors `AlertDestinationType`, but `"slack"` is deliberately retained as the + * *dialect* for Slack deliveries even though the legacy `slack` webhook + * destination type is gone: it is the key already stored in customers' template + * overrides, and `slack-bot` renders the same mrkdwn blocks. + */ +type TemplateDialect = AlertDestinationType | "slack" + const renderTitleBody = ( context: TemplateRenderContext, - destinationType: AlertDestinationType, + dialect: TemplateDialect, linkUrl: string, chatUrl: string, ): { title: string; body: string } | null => { - const resolved = resolveTemplate(context.template, destinationType) + const resolved = resolveTemplate(context.template, dialect) if (!hasCustomTemplate(resolved)) return null try { const templateCtx = buildTemplateContext(context, linkUrl, chatUrl) @@ -684,52 +693,6 @@ export const dispatchDelivery = ( Effect.gen(function* () { return yield* Match.value(context.secretConfig).pipe( Match.discriminatorsExhaustive("type")({ - slack: (config) => - Effect.gen(function* () { - const templated = renderTitleBody(context, "slack", linkUrl, chatUrl) - const blocks = templated - ? buildSlackBlocksFromTemplate( - templated.title, - templated.body, - context, - linkUrl, - chatUrl, - ) - : buildSlackBlocks(context, linkUrl, chatUrl) - const response = yield* runTimedFetch("slack", "Slack", fetchFn, timeoutMs, () => - safeFetch(config.webhookUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - // No top-level `text`: alongside attachments (and no top-level - // blocks) Slack renders it as a duplicate line above the color - // bar. `fallback` carries the notification-preview one-liner. - attachments: [ - { - color: slackAttachmentColor(context.eventType, context.severity), - fallback: templated?.title ?? buildSlackFallbackText(context), - blocks, - }, - ], - }), - fetchFn, - }), - ) - if (!response.ok) { - const detail = yield* readErrorBody(response) - return yield* Effect.fail( - makeDeliveryError( - `Slack delivery failed with ${response.status}${detail ? `: ${detail}` : ""}`, - "slack", - ), - ) - } - return { - providerMessage: "Delivered to Slack", - providerReference: null, - responseCode: response.status, - } as DispatchResult - }), "slack-bot": (config) => Effect.gen(function* () { const botToken = yield* deps.resolveSlackBotToken(context.destination.orgId) diff --git a/apps/api/src/services/AlertDestinationHydration.test.ts b/apps/api/src/services/AlertDestinationHydration.test.ts index 90cd6ef92..a86ddd6ed 100644 --- a/apps/api/src/services/AlertDestinationHydration.test.ts +++ b/apps/api/src/services/AlertDestinationHydration.test.ts @@ -112,13 +112,14 @@ describe("hydrateDestinationRow", () => { const testDb = createTestDb(createdDbs) const id = asDestinationId("00000000-0000-4000-8000-000000000002") const secretConfig: DestinationSecretConfig = { - type: "slack", - webhookUrl: "https://hooks.slack.com/services/T000/B000/xxx", + type: "slack-bot", + channelId: "C0789CHAN", + channelName: "alerts", } return Effect.gen(function* () { yield* seedDestination({ id, - type: "slack", + type: "slack-bot", publicConfig: { summary: "#alerts", channelLabel: "#alerts", diff --git a/apps/api/src/services/AlertDestinationHydration.ts b/apps/api/src/services/AlertDestinationHydration.ts index c6257f320..6b9a7115b 100644 --- a/apps/api/src/services/AlertDestinationHydration.ts +++ b/apps/api/src/services/AlertDestinationHydration.ts @@ -20,10 +20,6 @@ export const DestinationPublicConfigSchema = Schema.Struct({ }) const DestinationSecretConfigSchema = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("slack"), - webhookUrl: Schema.String, - }), Schema.Struct({ type: Schema.Literal("slack-bot"), // No secret token here — the bot token is resolved from the org's diff --git a/apps/api/src/services/AlertsService.test.ts b/apps/api/src/services/AlertsService.test.ts index f9a28d49e..613b4b527 100644 --- a/apps/api/src/services/AlertsService.test.ts +++ b/apps/api/src/services/AlertsService.test.ts @@ -3024,3 +3024,58 @@ describe("AlertsService.previewRule", () => { }).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: okFetch }))) }) }) + +// Rows written before a destination type was retired (the legacy `slack` +// webhook destination) are still in Postgres. Reads must tolerate them: a +// throwing decode on `alert_destinations.type` would defect, which surfaces as +// a bare 500 on every list for the whole org — not just for that one row. +describe("retired destination types", () => { + it.effect("skips an undecodable stored type instead of failing the destinations list", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const alerts = yield* AlertsService + const orgId = asOrgId("org_legacy_type") + const userId = asUserId("user_legacy_type") + const legacy = yield* createWebhookDestination(alerts, orgId, userId) + const supported = yield* alerts.createDestination(orgId, userId, adminRoles, { + type: "discord", + name: "Discord", + enabled: true, + webhookUrl: "https://discord.com/api/webhooks/1/token", + }) + const rule = yield* createErrorRateRule(alerts, orgId, userId, legacy.id) + yield* Effect.promise(() => + executeSql(testDb, "update alert_destinations set type = 'slack' where id = $1", [ + legacy.id, + ]), + ) + yield* Effect.promise(() => + insertDeliveryEventRow(testDb, { + id: "00000000-0000-4000-8000-00000000dead", + orgId, + incidentId: null, + ruleId: rule.id, + destinationId: legacy.id, + deliveryKey: "legacy-delivery", + eventType: "trigger", + attemptNumber: 1, + status: "success", + scheduledAt: DEFAULT_CLOCK_EPOCH_MS, + payloadJson: JSON.stringify({}), + }), + ) + + const list = yield* alerts.listDestinations(orgId) + assert.deepStrictEqual( + list.destinations.map((destination) => destination.id), + [supported.id], + ) + + // Delivery history still renders; the retired type falls back the same + // way a deleted destination does. + const events = yield* alerts.listDeliveryEvents(orgId) + assert.lengthOf(events.events, 1) + assert.strictEqual(events.events[0]?.destinationType, "webhook") + }).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub({}), { fetch: okFetch }))) + }) +}) diff --git a/apps/api/src/services/AlertsService.ts b/apps/api/src/services/AlertsService.ts index 78756bf80..431f931b7 100644 --- a/apps/api/src/services/AlertsService.ts +++ b/apps/api/src/services/AlertsService.ts @@ -307,7 +307,15 @@ const decodeQuerySpecSync = Schema.decodeUnknownSync(QuerySpec) const decodeIsoDateTimeStringSync = Schema.decodeUnknownSync(AlertDestinationDocument.fields.createdAt) const decodeRoleNameSync = Schema.decodeUnknownSync(RoleName) const decodeUserIdSync = Schema.decodeUnknownSync(UserIdSchema) -const decodeAlertDestinationTypeSync = Schema.decodeUnknownSync(AlertDestinationTypeSchema) +/** + * Stored `alert_destinations.type` values outlive the union: rows written before + * a destination type was retired (the legacy `slack` webhook variant) are still + * in Postgres. Decoding one of those with the throwing decoder raises a *defect*, + * turning a single stale row into a bare 500 for every destination list and + * delivery-history read in the org — so read paths decode tolerantly and skip or + * substitute, the same way `safeParsePublicConfig` tolerates an unusable config. + */ +const decodeAlertDestinationTypeOption = Schema.decodeUnknownOption(AlertDestinationTypeSchema) const decodeAlertSeveritySync = Schema.decodeUnknownSync(AlertSeveritySchema) const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema) const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema) @@ -585,10 +593,6 @@ const buildPublicConfig = ( ): DestinationPublicConfig => Match.value(request).pipe( Match.discriminatorsExhaustive("type")({ - slack: (r) => ({ - summary: r.channelLabel?.trim() || "Slack incoming webhook", - channelLabel: normalizeOptionalString(r.channelLabel), - }), "slack-bot": (r) => ({ summary: r.channelName?.trim() ? `#${r.channelName.trim()}` : "Slack channel", channelLabel: r.channelName?.trim() ? `#${r.channelName.trim()}` : null, @@ -629,10 +633,6 @@ const buildSecretConfig = ( ): DestinationSecretConfig => Match.value(request).pipe( Match.discriminatorsExhaustive("type")({ - slack: (r) => ({ - type: "slack" as const, - webhookUrl: r.webhookUrl.trim(), - }), "slack-bot": (r) => ({ type: "slack-bot" as const, channelId: r.channelId.trim(), @@ -900,11 +900,15 @@ const parseCompiledPlan = ( ) } -const rowToDestinationDocument = (row: AlertDestinationRow, publicConfig: DestinationPublicConfig) => +const rowToDestinationDocument = ( + row: AlertDestinationRow, + publicConfig: DestinationPublicConfig, + type: AlertDestinationType, +) => new AlertDestinationDocument({ id: decodeAlertDestinationIdSync(row.id), name: row.name, - type: decodeAlertDestinationTypeSync(row.type), + type, enabled: row.enabled, summary: publicConfig.summary, channelLabel: publicConfig.channelLabel, @@ -915,6 +919,32 @@ const rowToDestinationDocument = (row: AlertDestinationRow, publicConfig: Destin updatedAt: decodeIsoDateTimeStringSync(row.updatedAt.toISOString()), }) +/** + * `None` when the row's `type` is no longer a supported destination. Callers pick + * the behaviour: lists drop the row, single-row reads fail with a typed error. + */ +const rowToDestinationDocumentOption = ( + row: AlertDestinationRow, + publicConfig: DestinationPublicConfig, +): Option.Option => + Option.map(decodeAlertDestinationTypeOption(row.type), (type) => + rowToDestinationDocument(row, publicConfig, type), + ) + +/** + * Single-row variant: a retired `type` surfaces as a typed validation error, + * mirroring how an unparseable stored config already fails, instead of a defect. + */ +const destinationDocumentFromRow = ( + row: AlertDestinationRow, + publicConfig: DestinationPublicConfig, +): Effect.Effect => + Option.match(rowToDestinationDocumentOption(row, publicConfig), { + onNone: () => + Effect.fail(makeValidationError("Stored destination type is no longer supported", ["type"])), + onSome: Effect.succeed, + }) + const serviceNamesFromRow = (row: AlertRuleRow): ReadonlyArray => row.serviceNamesJson ? safeParseStringArray(row.serviceNamesJson) : [] @@ -1299,7 +1329,7 @@ export class AlertsService extends Context.Service - rowToDestinationDocument(row, safeParsePublicConfig(row)), - ) + // Drop rows whose stored `type` no longer decodes (legacy `slack` + // webhook destinations) — one of them must not 500 the whole list. + const destinations = rows + .map((row) => rowToDestinationDocumentOption(row, safeParsePublicConfig(row))) + .filter(Option.isSome) + .map((option) => option.value) return new AlertDestinationsListResponse({ destinations }) }) @@ -2097,9 +2130,7 @@ export class AlertsService extends Context.Service 0 - ) { - yield* validateDestinationUrl(request.webhookUrl, "webhookUrl") - } else if ( - request.type === "webhook" && - request.url != null && - request.url.trim().length > 0 - ) { + if (request.type === "webhook" && request.url != null && request.url.trim().length > 0) { yield* validateDestinationUrl(request.url, "url") } else if ( request.type === "hazel" && @@ -2222,25 +2243,6 @@ export class AlertsService extends Context.Service - Effect.succeed({ - nextPublicConfig: { - summary: - normalizeOptionalString(r.channelLabel) ?? - hydrated.publicConfig.summary, - channelLabel: - normalizeOptionalString(r.channelLabel) ?? - hydrated.publicConfig.channelLabel, - } satisfies DestinationPublicConfig, - nextSecretConfig: { - type: "slack" as const, - webhookUrl: - normalizeOptionalString(r.webhookUrl) ?? - (hydrated.secretConfig.type === "slack" - ? hydrated.secretConfig.webhookUrl - : ""), - } satisfies DestinationSecretConfig, - }), "slack-bot": (r) => { const channelName = normalizeOptionalString(r.channelName) return Effect.succeed({ @@ -2482,7 +2484,7 @@ export class AlertsService extends Context.Service "webhook" as const, + ), deliveryKey: row.deliveryKey, eventType: decodeAlertEventTypeSync(row.eventType), attemptNumber: row.attemptNumber, diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md index 194e6a41c..18a2cf314 100644 --- a/apps/landing/src/content/docs/alerting/notification-destinations.md +++ b/apps/landing/src/content/docs/alerting/notification-destinations.md @@ -17,11 +17,13 @@ If a test fails, Maple surfaces the provider's own rejection reason in the toast ## Slack -Post alerts to a Slack channel via an [incoming webhook](https://api.slack.com/messaging/webhooks). +Post alerts to a Slack channel through the Maple Slack app — there is no webhook to create or rotate. -1. Create a Slack app (or use an existing one) and enable **Incoming Webhooks**. -2. Add a webhook to the channel you want alerts in. -3. Copy the `https://hooks.slack.com/services/...` URL into the **Slack webhook URL** field. +1. In the Maple dashboard, open **Integrations → Slack** and click **Connect** to install the Maple app into your Slack workspace. +2. Back under **Alerts → Destinations**, click **Add destination** and pick **Slack**. +3. Choose the channel the bot should post to. For private channels, invite the Maple app to the channel first. + +Listing the workspace's channels is limited to org admins; other members can still create destinations once an admin has picked a channel. ## PagerDuty diff --git a/apps/web/src/components/alerts/destination-card.tsx b/apps/web/src/components/alerts/destination-card.tsx index c1c0a8672..89bc12191 100644 --- a/apps/web/src/components/alerts/destination-card.tsx +++ b/apps/web/src/components/alerts/destination-card.tsx @@ -1,5 +1,5 @@ import type { AlertDestinationDocument } from "@maple/domain/http" -import { PROVIDERS, ProviderLogo } from "@/components/alerts/destination-provider" +import { getProvider, ProviderLogo } from "@/components/alerts/destination-provider" import { AlertWarningIcon, CheckIcon, @@ -42,7 +42,7 @@ export function DestinationCard({ onEdit, onDelete, }: DestinationCardProps) { - const provider = PROVIDERS[destination.type] + const provider = getProvider(destination.type) return ( {isEditing ? "Listing this workspace's Slack channels is limited to org admins, so the channel can't be changed here. Your other edits still save — ask an admin to move this destination to another channel." - : "Listing this workspace's Slack channels is limited to org admins, so this destination can't be finished here. Ask an admin to create it, or use a Slack webhook destination — any member can set one up."} + : "Listing this workspace's Slack channels is limited to org admins, so this destination can't be finished here. Ask an admin to create it for you."}

- {!isEditing ? ( - - ) : null} ) } @@ -887,49 +868,6 @@ export function DestinationDialog({ /> - {form.type === "slack" && ( - <> -
- - - onFormChange((current) => ({ - ...current, - webhookUrl: event.target.value, - })) - } - placeholder={ - isEditing - ? "Leave blank to keep current webhook" - : "https://hooks.slack.com/services/..." - } - className="font-mono text-xs" - /> -
-
- - - onFormChange((current) => ({ - ...current, - channelLabel: event.target.value, - })) - } - placeholder="#ops-alerts" - className="font-mono text-xs" - /> -
- - )} - {form.type === "slack-bot" && ( = { - slack: { - type: "slack", - label: "Slack (webhook)", - description: "Post alerts to a Slack channel via an incoming webhook you manage.", - accent: SLACK_ACCENT, - accentBg: SLACK_ACCENT_BG, - accentText: SLACK_ACCENT_TEXT, - // White clears both halves of the aubergine pair: 14.0:1 on #4A154B (light), - // 4.65:1 on #AD51A7 (dark) — the dark stand-in was picked for this margin. - accentOn: INK_ON_DARK_ACCENT, - // Deliberately no `brandfetchDomain`: the bot entry renders the local - // SlackIcon and the two rows sit next to each other — a remote CDN bitmap - // here would make the same product look like two. - fallbackIcon: ({ size = 22, className }) => , - docsUrl: "https://api.slack.com/messaging/webhooks", - docsLabel: "Slack webhook docs", - }, "slack-bot": { type: "slack-bot", label: "Slack (bot)", @@ -102,7 +85,7 @@ export const PROVIDERS: Record = { accent: SLACK_ACCENT, accentBg: SLACK_ACCENT_BG, accentText: SLACK_ACCENT_TEXT, - // Same aubergine pair as the webhook row — 14.0:1 light / 4.65:1 dark. + // 14.0:1 on #4A154B (light), 4.65:1 on #AD51A7 (dark). accentOn: INK_ON_DARK_ACCENT, fallbackIcon: ({ size = 22, className }) => , docsUrl: "https://maple.dev/docs/integrations/slack", @@ -195,10 +178,24 @@ export const PROVIDERS: Record = { }, } -// The recommended bot flow leads; the legacy webhook entry trails it. +/** + * Never index {@link PROVIDERS} with a value that came from the database — a + * retired destination type (the legacy `slack` webhook row) misses the record + * and every `provider.*` read below would throw on `undefined`, taking the whole + * Alerts page down. Such rows are already filtered out upstream; this is the + * belt-and-braces so a lookup miss degrades to a neutral card instead. + */ +const UNKNOWN_PROVIDER: DestinationProvider = { + ...PROVIDERS.webhook, + label: "Unknown", + description: "This destination type is no longer supported.", +} + +export const getProvider = (type: AlertDestinationType): DestinationProvider => + PROVIDERS[type] ?? UNKNOWN_PROVIDER + export const DESTINATION_TYPES: ReadonlyArray = [ "slack-bot", - "slack", "discord", "email", "pagerduty", @@ -217,7 +214,7 @@ interface ProviderLogoProps { } export function ProviderLogo({ type, size = 40, className, bare }: ProviderLogoProps) { - const provider = PROVIDERS[type] + const provider = getProvider(type) const [errored, setErrored] = useState(false) const inner = size - 14 diff --git a/apps/web/src/hooks/use-alerts-list.ts b/apps/web/src/hooks/use-alerts-list.ts index e020da47a..279f079b7 100644 --- a/apps/web/src/hooks/use-alerts-list.ts +++ b/apps/web/src/hooks/use-alerts-list.ts @@ -116,7 +116,12 @@ export function useAlertDestinationsList(): AlertDestinationsListHook { const result = useMemo>(() => { if (isLoading && (rows?.length ?? 0) === 0) return Result.initial(true) - const destinations = (rows ?? []).map(rowToAlertDestinationDocument) + // `rowToAlertDestinationDocument` returns null for a row whose type is no + // longer supported; those are skipped instead of crashing the render. + const destinations = (rows ?? []).flatMap((row) => { + const document = rowToAlertDestinationDocument(row) + return document === null ? [] : [document] + }) return Result.success(new AlertDestinationsListResponse({ destinations })) }, [rows, isLoading]) diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts index 03233d641..b6df45591 100644 --- a/apps/web/src/lib/alerts/form-utils.ts +++ b/apps/web/src/lib/alerts/form-utils.ts @@ -465,8 +465,7 @@ export type DestinationFormState = { type: AlertDestinationType name: string enabled: boolean - channelLabel: string - /** Slack and Discord both use this incoming-webhook URL field. */ + /** Discord incoming-webhook URL. */ webhookUrl: string /** * Slack (bot) destination: the channel the installed Maple bot posts to. @@ -491,16 +490,12 @@ export type DestinationFormState = { export const MAX_EMAIL_MEMBER_RECIPIENTS = 10 -/** - * Defaults to `slack-bot` — the recommended flow, and the tile the dialog lists - * first (`DESTINATION_TYPES`); the legacy `slack` webhook trails it. - */ +/** Defaults to `slack-bot` — the tile the dialog lists first (`DESTINATION_TYPES`). */ export function defaultDestinationForm(type: AlertDestinationType = "slack-bot"): DestinationFormState { return { type, name: "", enabled: true, - channelLabel: "", webhookUrl: "", slackChannelId: "", slackChannelName: "", @@ -522,7 +517,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D type: destination.type, name: destination.name, enabled: destination.enabled, - channelLabel: destination.channelLabel ?? "", webhookUrl: "", // slack-bot hydrates `channelLabel` as `#name`; keep the current channel // visible on edit (its id isn't returned — an empty id keeps the stored one). @@ -544,16 +538,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D export function buildDestinationCreateParamsV2(form: DestinationFormState): V2AlertDestinationCreateParams { switch (form.type) { - case "slack": { - const channelLabel = form.channelLabel.trim() - return { - type: "slack", - name: form.name.trim(), - enabled: form.enabled, - webhook_url: form.webhookUrl.trim(), - ...(channelLabel ? { channel_label: channelLabel } : {}), - } - } case "slack-bot": { const channelName = form.slackChannelName.trim() return { @@ -630,17 +614,6 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2AlertDestinationUpdateParams { const name = form.name.trim() switch (form.type) { - case "slack": { - const channelLabel = form.channelLabel.trim() - const webhookUrl = form.webhookUrl.trim() - return { - type: "slack", - enabled: form.enabled, - ...(name ? { name } : {}), - ...(channelLabel ? { channel_label: channelLabel } : {}), - ...(webhookUrl ? { webhook_url: webhookUrl } : {}), - } - } case "slack-bot": { const channelId = form.slackChannelId.trim() const channelName = form.slackChannelName.trim() diff --git a/apps/web/src/lib/collections/alerts.test.ts b/apps/web/src/lib/collections/alerts.test.ts index 5cb1c8709..ce109add0 100644 --- a/apps/web/src/lib/collections/alerts.test.ts +++ b/apps/web/src/lib/collections/alerts.test.ts @@ -190,11 +190,11 @@ describe("rowToAlertDestinationDocument", () => { id: DEST_ID, org_id: "org_1", name: "Ops Slack", - type: "slack", + type: "slack-bot", enabled: true, // Only the public config the browser renders — no secrets (those live in // the excluded encrypted columns, which the shape never projects). - config_json: { summary: "Slack incoming webhook", channelLabel: "#ops" }, + config_json: { summary: "#ops", channelLabel: "#ops" }, last_tested_at: "2026-07-04T00:00:00.000Z", last_test_error: null, created_at: "2026-06-01T00:00:00.000Z", @@ -202,12 +202,12 @@ describe("rowToAlertDestinationDocument", () => { } it("maps a raw alert_destinations row and derives the public config", () => { - const doc = rowToAlertDestinationDocument(base) + const doc = rowToAlertDestinationDocument(base)! assert.strictEqual(doc.id, DEST_ID) assert.strictEqual(doc.name, "Ops Slack") - assert.strictEqual(doc.type, "slack") + assert.strictEqual(doc.type, "slack-bot") assert.strictEqual(doc.enabled, true) - assert.strictEqual(doc.summary, "Slack incoming webhook") + assert.strictEqual(doc.summary, "#ops") assert.strictEqual(doc.channelLabel, "#ops") assert.strictEqual(doc.lastTestedAt, "2026-07-04T00:00:00.000Z") assert.strictEqual(doc.lastTestError, null) @@ -215,13 +215,20 @@ describe("rowToAlertDestinationDocument", () => { }) it("falls back to the server's invalid-config summary when config_json is unusable", () => { - const doc = rowToAlertDestinationDocument({ ...base, config_json: { nope: true } }) + const doc = rowToAlertDestinationDocument({ ...base, config_json: { nope: true } })! assert.strictEqual(doc.summary, "Invalid destination config") assert.strictEqual(doc.channelLabel, null) }) it("leaves lastTestedAt null when the destination was never tested", () => { - const doc = rowToAlertDestinationDocument({ ...base, last_tested_at: null }) + const doc = rowToAlertDestinationDocument({ ...base, last_tested_at: null })! assert.strictEqual(doc.lastTestedAt, null) }) + + // Electric syncs whatever is in Postgres, including rows written before a + // destination type was retired (the legacy `slack` webhook destination). + // Decoding those threw during render and white-screened the Alerts page. + it("returns null for a row whose type is no longer a supported destination", () => { + assert.strictEqual(rowToAlertDestinationDocument({ ...base, type: "slack" }), null) + }) }) diff --git a/apps/web/src/lib/collections/alerts.ts b/apps/web/src/lib/collections/alerts.ts index 2a1c9a30c..f10022706 100644 --- a/apps/web/src/lib/collections/alerts.ts +++ b/apps/web/src/lib/collections/alerts.ts @@ -30,7 +30,13 @@ const asUserId = Schema.decodeUnknownSync(UserId) const asErrorIssueId = Schema.decodeUnknownSync(ErrorIssueId) const decodeDestinationId = Schema.decodeUnknownSync(AlertDestinationId) -const asDestinationType = Schema.decodeUnknownSync(AlertDestinationType) +/** + * Tolerant on purpose: Electric syncs raw `alert_destinations` rows, and a row + * whose `type` was retired from the union (the legacy `slack` webhook + * destination) would throw here — during render, white-screening the Alerts + * page. `None` → the row is dropped, matching the server's `listDestinations`. + */ +const decodeDestinationType = Schema.decodeUnknownOption(AlertDestinationType) const asSeverity = Schema.decodeUnknownSync(AlertSeverity) const asSignalType = Schema.decodeUnknownSync(AlertSignalType) @@ -342,8 +348,13 @@ const decodeDestinationPublicConfig = Schema.decodeUnknownOption(AlertDestinatio * {@link AlertDestinationDocument}, mirroring `rowToDestinationDocument` + * `safeParsePublicConfig` in AlertsService.ts (invalid config → the same * "Invalid destination config" fallback the server uses). + * + * Returns `null` for a row whose `type` is no longer a supported destination; + * callers skip it, exactly as the server's `listDestinations` does. */ -export const rowToAlertDestinationDocument = (row: AlertDestinationRow): AlertDestinationDocument => { +export const rowToAlertDestinationDocument = ( + row: AlertDestinationRow, +): AlertDestinationDocument | null => { const publicConfig = Option.getOrElse( decodeDestinationPublicConfig(row.config_json), (): Schema.Schema.Type => ({ @@ -351,10 +362,12 @@ export const rowToAlertDestinationDocument = (row: AlertDestinationRow): AlertDe channelLabel: null, }), ) + const type = decodeDestinationType(row.type) + if (Option.isNone(type)) return null return new AlertDestinationDocument({ id: decodeDestinationId(row.id), name: row.name, - type: asDestinationType(row.type), + type: type.value, enabled: row.enabled, summary: publicConfig.summary, channelLabel: publicConfig.channelLabel, diff --git a/examples/alchemy-maple/README.md b/examples/alchemy-maple/README.md index 6d3c83559..ec0c1b7de 100644 --- a/examples/alchemy-maple/README.md +++ b/examples/alchemy-maple/README.md @@ -6,7 +6,7 @@ A Cloudflare Worker and the Maple resources that observe it, in one stack — vi | | | | ---------------------------------- | -------------------------------------------------------------------------- | | [`src/Api.ts`](src/Api.ts) | The Worker: Alchemy's Effect-native style, instrumented with the Maple SDK | -| [`alchemy.run.ts`](alchemy.run.ts) | A Slack destination, two alert rules, a dashboard, a scoped API key | +| [`alchemy.run.ts`](alchemy.run.ts) | A Discord destination, two alert rules, a dashboard, a scoped API key | ## The seam @@ -35,7 +35,7 @@ bun run --cwd ../../lib/alchemy-maple build && bun run --cwd ../../lib/effect-sd ``` ```bash -MAPLE_API_KEY=maple_ak_… CLOUDFLARE_ACCOUNT_ID=… SLACK_WEBHOOK_URL=https://hooks.slack.com/… bun alchemy deploy +MAPLE_API_KEY=maple_ak_… CLOUDFLARE_ACCOUNT_ID=… DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/… bun alchemy deploy ``` Needs an org-admin `maple_ak_…` key (alert rules and API keys require one); set `MAPLE_API_URL` to diff --git a/examples/alchemy-maple/alchemy.run.ts b/examples/alchemy-maple/alchemy.run.ts index c3bc2bc8c..78e1e6bc4 100644 --- a/examples/alchemy-maple/alchemy.run.ts +++ b/examples/alchemy-maple/alchemy.run.ts @@ -27,11 +27,11 @@ export default Alchemy.Stack( const api = yield* Api // Where alerts go. Channel secrets are write-only server-side. - const slack = yield* Maple.AlertDestination("oncall-slack", { - type: "slack", - name: "On-call Slack", - webhook_url: process.env.SLACK_WEBHOOK_URL ?? "https://hooks.slack.com/services/CHANGE/ME/PLEASE", - channel_label: "#incidents", + const oncall = yield* Maple.AlertDestination("oncall-discord", { + type: "discord", + name: "On-call Discord", + webhook_url: + process.env.DISCORD_WEBHOOK_URL ?? "https://discord.com/api/webhooks/CHANGE/ME_PLEASE", }) // Alerts on the service name the Worker reports as — `SERVICE_NAME` is the @@ -46,7 +46,7 @@ export default Alchemy.Stack( threshold: 0.05, // error rates are 0–1 ratios, not percentages window_minutes: 5, service_names: [SERVICE_NAME], - destination_ids: [slack.destinationId], + destination_ids: [oncall.destinationId], }) yield* Maple.AlertRule("checkout-p95", { @@ -57,7 +57,7 @@ export default Alchemy.Stack( threshold: 750, window_minutes: 10, service_names: [SERVICE_NAME], - destination_ids: [slack.destinationId], + destination_ids: [oncall.destinationId], }) yield* Maple.Dashboard("service-health", { diff --git a/lib/alchemy-maple/README.md b/lib/alchemy-maple/README.md index 9c73576fc..eb863098b 100644 --- a/lib/alchemy-maple/README.md +++ b/lib/alchemy-maple/README.md @@ -22,11 +22,10 @@ export default Alchemy.Stack( { providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()) }, Effect.gen(function* () { // A notification channel… - const slack = yield* Maple.AlertDestination("oncall", { - type: "slack", - name: "On-call Slack", - webhook_url: process.env.SLACK_WEBHOOK_URL!, - channel_label: "#incidents", + const oncall = yield* Maple.AlertDestination("oncall", { + type: "discord", + name: "On-call Discord", + webhook_url: process.env.DISCORD_WEBHOOK_URL!, }) // …an alert rule that delivers to it (dependency resolved automatically)… @@ -37,7 +36,7 @@ export default Alchemy.Stack( comparator: "gt", threshold: 0.05, // error rates are 0–1 ratios window_minutes: 5, - destination_ids: [slack.destinationId], + destination_ids: [oncall.destinationId], }) // …a dashboard… @@ -79,7 +78,7 @@ Set `MAPLE_API_KEY` to a Maple API key (create one in the Maple dashboard, or wi | Resource | Semantics | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Maple.Dashboard` | Full CRUD. Props are the v2 wire shape (`snake_case`, see `/v2/docs`); widget/variable documents pass through verbatim. Updates PATCH in place. | -| `Maple.AlertDestination` | Full CRUD. Discriminated on `type` (slack, pagerduty, webhook, hazel, discord, email). Channel secrets are write-only — accept `Redacted` values. Changing `type` replaces the destination. | +| `Maple.AlertDestination` | Full CRUD. Discriminated on `type` (pagerduty, webhook, hazel, discord, email). Channel secrets are write-only — accept `Redacted` values. Changing `type` replaces the destination. | | `Maple.AlertRule` | Full CRUD. `destination_ids` accepts outputs from `Maple.AlertDestination`. Rule names are org-unique, so lost state is re-adopted by name instead of duplicating. | | `Maple.ApiKey` | Create / roll / revoke (the API has no key update). Changing props replaces the key; bumping the `rotate` prop rolls it in place (same name/scopes, new secret). `secret` is captured once and preserved in Alchemy state — it can never be re-read from the API. | | `Maple.IngestKeys` | Read-only per-org singleton. Surfaces `publicKey` / `privateKey` as `Redacted` outputs; delete only stops tracking it. | diff --git a/lib/alchemy-maple/src/AlertDestination.ts b/lib/alchemy-maple/src/AlertDestination.ts index 7828e4483..c31a824d6 100644 --- a/lib/alchemy-maple/src/AlertDestination.ts +++ b/lib/alchemy-maple/src/AlertDestination.ts @@ -24,7 +24,6 @@ interface DestinationBaseProps { * prop changes only. */ export type AlertDestinationProps = - | (DestinationBaseProps & { type: "slack"; webhook_url: SecretInput; channel_label?: string }) | (DestinationBaseProps & { type: "pagerduty"; integration_key: SecretInput }) | (DestinationBaseProps & { type: "webhook"; url: string; signing_secret?: SecretInput }) | (DestinationBaseProps & { type: "hazel"; webhook_url: SecretInput; signing_secret?: SecretInput }) @@ -46,16 +45,15 @@ export type AlertDestination = Resource< > /** - * A notification channel (Slack, PagerDuty, webhook, Hazel, Discord, or + * A notification channel (PagerDuty, webhook, Hazel, Discord, or * workspace-member email) that `Maple.AlertRule`s deliver to. * * @example * ```typescript - * const slack = yield* Maple.AlertDestination("oncall", { - * type: "slack", - * name: "On-call Slack", - * webhook_url: Redacted.make(process.env.SLACK_WEBHOOK_URL!), - * channel_label: "#incidents", + * const oncall = yield* Maple.AlertDestination("oncall", { + * type: "pagerduty", + * name: "On-call PagerDuty", + * integration_key: Redacted.make(process.env.PAGERDUTY_ROUTING_KEY!), * }) * ``` */ @@ -90,10 +88,6 @@ const desiredBody = (props: AlertDestinationProps): Record => { const body: Record = { type: props.type, name: props.name } if (props.enabled !== undefined) body.enabled = props.enabled switch (props.type) { - case "slack": - body.webhook_url = unwrap(props.webhook_url) - if (props.channel_label !== undefined) body.channel_label = props.channel_label - break case "pagerduty": body.integration_key = unwrap(props.integration_key) break @@ -122,12 +116,7 @@ const desiredBody = (props: AlertDestinationProps): Record => { const drifted = ( props: AlertDestinationProps, observed: Schema.Schema.Type, -): boolean => - props.name !== observed.name || - (props.enabled ?? true) !== observed.enabled || - (props.type === "slack" && - props.channel_label !== undefined && - props.channel_label !== (observed.channel_label ?? undefined)) +): boolean => props.name !== observed.name || (props.enabled ?? true) !== observed.enabled const toAttributes = (observed: Schema.Schema.Type) => ({ destinationId: observed.id, diff --git a/lib/alchemy-maple/src/AlertRule.ts b/lib/alchemy-maple/src/AlertRule.ts index 78315a6ec..80406e738 100644 --- a/lib/alchemy-maple/src/AlertRule.ts +++ b/lib/alchemy-maple/src/AlertRule.ts @@ -78,7 +78,7 @@ export type AlertRule = Resource< * * @example * ```typescript - * const slack = yield* Maple.AlertDestination("oncall", { ... }) + * const oncall = yield* Maple.AlertDestination("oncall", { ... }) * yield* Maple.AlertRule("checkout-errors", { * name: "Checkout error rate", * severity: "critical", @@ -86,7 +86,7 @@ export type AlertRule = Resource< * comparator: "gt", * threshold: 0.05, * window_minutes: 5, - * destination_ids: [slack.destinationId], + * destination_ids: [oncall.destinationId], * }) * ``` */ diff --git a/lib/alchemy-maple/src/index.ts b/lib/alchemy-maple/src/index.ts index 89a6f0fab..db68ecd92 100644 --- a/lib/alchemy-maple/src/index.ts +++ b/lib/alchemy-maple/src/index.ts @@ -14,10 +14,10 @@ * export default Alchemy.Stack("my-app", { * providers: Maple.providers(), * }, Effect.gen(function* () { - * const slack = yield* Maple.AlertDestination("oncall", { - * type: "slack", - * name: "On-call Slack", - * webhook_url: process.env.SLACK_WEBHOOK_URL!, + * const oncall = yield* Maple.AlertDestination("oncall", { + * type: "discord", + * name: "On-call Discord", + * webhook_url: process.env.DISCORD_WEBHOOK_URL!, * }) * yield* Maple.AlertRule("checkout-errors", { * name: "Checkout error rate", @@ -26,7 +26,7 @@ * comparator: "gt", * threshold: 0.05, * window_minutes: 5, - * destination_ids: [slack.destinationId], + * destination_ids: [oncall.destinationId], * }) * })) * ``` diff --git a/lib/alchemy-maple/test/alert-destination-props.test.ts b/lib/alchemy-maple/test/alert-destination-props.test.ts index 249e9c748..9c66f0437 100644 --- a/lib/alchemy-maple/test/alert-destination-props.test.ts +++ b/lib/alchemy-maple/test/alert-destination-props.test.ts @@ -18,7 +18,6 @@ import { // Every channel type is constructible with its own fields. export const accepts = Effect.gen(function* () { - yield* AlertDestination("slack", { type: "slack", name: "s", webhook_url: "u", channel_label: "#c" }) yield* AlertDestination("pagerduty", { type: "pagerduty", name: "p", integration_key: "k" }) yield* AlertDestination("webhook", { type: "webhook", name: "w", url: "https://x", signing_secret: "s" }) yield* AlertDestination("hazel", { type: "hazel", name: "h", webhook_url: "u" }) @@ -28,16 +27,15 @@ export const accepts = Effect.gen(function* () { // …and only with its own fields. export const rejects = Effect.gen(function* () { - // @ts-expect-error slack requires webhook_url - yield* AlertDestination("a", { type: "slack", name: "no secret" }) + // @ts-expect-error discord requires webhook_url + yield* AlertDestination("a", { type: "discord", name: "no secret" }) // @ts-expect-error integration_key belongs to pagerduty - yield* AlertDestination("b", { type: "slack", name: "x", webhook_url: "u", integration_key: "k" }) + yield* AlertDestination("b", { type: "discord", name: "x", webhook_url: "u", integration_key: "k" }) // @ts-expect-error email requires member_user_ids yield* AlertDestination("c", { type: "email", name: "x" }) }) const channels: ReadonlyArray<[string, AlertDestinationProps, string]> = [ - ["slack", { type: "slack", name: "s", webhook_url: "u" }, "webhook_url"], ["pagerduty", { type: "pagerduty", name: "p", integration_key: "k" }, "integration_key"], ["webhook", { type: "webhook", name: "w", url: "https://x" }, "url"], ["hazel", { type: "hazel", name: "h", webhook_url: "u" }, "webhook_url"], diff --git a/lib/alchemy-maple/test/contract.test.ts b/lib/alchemy-maple/test/contract.test.ts index 37dc9706c..900a89fcb 100644 --- a/lib/alchemy-maple/test/contract.test.ts +++ b/lib/alchemy-maple/test/contract.test.ts @@ -42,13 +42,6 @@ describe("provider request bodies decode against the real v2 create-param schema it("alert destination create bodies (each channel type)", () => { const bodies = [ - _alertDestinationCreateBody({ - type: "slack", - name: "On-call Slack", - webhook_url: "https://hooks.slack.com/services/T000/B000/XXXX", - channel_label: "#incidents", - enabled: true, - }), _alertDestinationCreateBody({ type: "pagerduty", name: "PD", integration_key: "key" }), _alertDestinationCreateBody({ type: "webhook", diff --git a/packages/domain/src/http/alerts.test.ts b/packages/domain/src/http/alerts.test.ts index f642c6464..e569573ab 100644 --- a/packages/domain/src/http/alerts.test.ts +++ b/packages/domain/src/http/alerts.test.ts @@ -5,7 +5,6 @@ import { AlertNotificationTemplate, AlertRuleUpsertRequest, PagerDutyAlertDestinationConfig, - SlackAlertDestinationConfig, WebhookAlertDestinationConfig, } from "./alerts" @@ -17,26 +16,6 @@ describe("AlertDestinationCreateRequest", () => { // side and produces the plain wire-format object on the output side. // These tests assert the encoded wire shape matches what HTTP clients // see on the wire. - it("encodes slack destination instances to the plain wire shape", () => { - expect( - encode( - new SlackAlertDestinationConfig({ - type: "slack", - name: "Ops Slack", - enabled: true, - webhookUrl: "https://hooks.slack.com/services/T/B/X", - channelLabel: "#ops-alerts", - }), - ), - ).toEqual({ - type: "slack", - name: "Ops Slack", - enabled: true, - webhookUrl: "https://hooks.slack.com/services/T/B/X", - channelLabel: "#ops-alerts", - }) - }) - it("encodes pagerduty and webhook destination instances to the plain wire shape", () => { expect( encode( @@ -78,25 +57,6 @@ describe("AlertDestinationCreateRequest", () => { // Decode goes the other direction: plain wire-format objects in, class // instances out. The union discriminates on `type`. - it("decodes a slack wire object into a SlackAlertDestinationConfig instance", () => { - const decoded = decode({ - type: "slack", - name: "Ops Slack", - enabled: true, - webhookUrl: "https://hooks.slack.com/services/T/B/X", - channelLabel: "#ops-alerts", - }) - - expect(decoded).toBeInstanceOf(SlackAlertDestinationConfig) - expect(decoded).toMatchObject({ - type: "slack", - name: "Ops Slack", - enabled: true, - webhookUrl: "https://hooks.slack.com/services/T/B/X", - channelLabel: "#ops-alerts", - }) - }) - it("decodes a pagerduty wire object into a PagerDutyAlertDestinationConfig instance", () => { const decoded = decode({ type: "pagerduty", @@ -141,12 +101,12 @@ describe("AlertDestinationCreateRequest", () => { expect(Exit.isFailure(result)).toBe(true) }) - it("fails to decode a slack destination missing the required webhookUrl", () => { + it("fails to decode a webhook destination missing the required url", () => { const result = decodeExit({ - type: "slack", - name: "Ops Slack", + type: "webhook", + name: "Webhook", enabled: true, - channelLabel: "#ops-alerts", + signingSecret: "secret", }) expect(Exit.isFailure(result)).toBe(true) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 57722b074..3c2e3db8d 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -19,7 +19,6 @@ import { QueryBuilderQueryDraftSchema } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" export const AlertDestinationType = Schema.Literals([ - "slack", "slack-bot", "pagerduty", "webhook", @@ -163,16 +162,6 @@ export const AlertWindowMinutes = PositiveInt.pipe( Schema.check(Schema.isLessThanOrEqualTo(MAX_ALERT_WINDOW_MINUTES)), ) -export class SlackAlertDestinationConfig extends Schema.Class( - "SlackAlertDestinationConfig", -)({ - type: Schema.Literal("slack"), - name: ChannelLabel, - webhookUrl: NonEmptyString, - channelLabel: OptionalNonEmptyString, - enabled: Schema.optionalKey(Schema.Boolean), -}) {} - export class SlackBotAlertDestinationConfig extends Schema.Class( "SlackBotAlertDestinationConfig", )({ @@ -258,7 +247,6 @@ export class EmailAlertDestinationConfig extends Schema.Class -export class UpdateSlackAlertDestinationConfig extends Schema.Class( - "UpdateSlackAlertDestinationConfig", -)({ - name: OptionalNonEmptyString, - webhookUrl: Schema.optionalKey(Schema.String), - channelLabel: OptionalNonEmptyString, - enabled: Schema.optionalKey(Schema.Boolean), -}) {} - export class UpdateSlackBotAlertDestinationConfig extends Schema.Class( "UpdateSlackBotAlertDestinationConfig", )({ @@ -342,10 +321,6 @@ export class UpdateEmailAlertDestinationConfig extends Schema.Class @@ -496,6 +472,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina OpenApi.annotations({ title: "Alert Destinations", description: - "Notification channels for alert rules — Slack, PagerDuty, generic webhooks, Hazel, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "Notification channels for alert rules — Slack (bot), PagerDuty, generic webhooks, Hazel, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", }), ) {} diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 8683908e4..704b8aa5e 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -304,13 +304,13 @@ describe("V2 alerts wire format", () => { }) it("decodes destination create params per union arm and rejects mismatched configs", () => { - const slack = Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({ - type: "slack", + const slackBot = Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({ + type: "slack-bot", name: "On-call", - webhook_url: "https://hooks.slack.com/services/T/B/X", - channel_label: "#incidents", + channel_id: "C0789CHAN", + channel_name: "incidents", }) - expect(slack.type).toBe("slack") + expect(slackBot.type).toBe("slack-bot") const email = Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({ type: "email", @@ -324,7 +324,7 @@ describe("V2 alerts wire format", () => { Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({ type: "pagerduty", name: "PD", - webhook_url: "https://hooks.slack.com/services/T/B/X", + webhook_url: "https://discord.com/api/webhooks/x", }), ).toThrow() })