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()
})