import { type ProjectAlertChannel, type ProjectAlertType, type RuntimeEnvironmentType, } from "@trigger.dev/database"; import { nanoid } from "nanoid"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; import { encryptSecret } from "~/services/secrets/secretStore.server"; import { alertsWorker } from "~/v3/alertsWorker.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { BaseService, ServiceValidationError } from "../baseService.server"; import { assertSafeWebhookUrl, UnsafeWebhookUrlError } from "./safeWebhookUrl.server"; export type CreateAlertChannelOptions = { name: string; alertTypes: ProjectAlertType[]; environmentTypes: RuntimeEnvironmentType[]; deduplicationKey?: string; channel: | { type: "EMAIL"; email: string; } | { type: "WEBHOOK"; url: string; secret?: string; } | { type: "SLACK"; channelId: string; channelName: string; integrationId: string | undefined; }; }; export class CreateAlertChannelService extends BaseService { public async call( projectRef: string, userId: string, options: CreateAlertChannelOptions ): Promise { const project = await findProjectByRef(projectRef, userId); if (!project) { throw new ServiceValidationError("Project not found"); } // Validate webhook URLs here (not per-route) so every caller is covered. // Delivery re-validates at connect time via safeWebhookFetch. if (options.channel.type === "WEBHOOK") { try { await assertSafeWebhookUrl(options.channel.url); } catch (error) { if (error instanceof UnsafeWebhookUrlError) { throw new ServiceValidationError(error.message); } throw error; } } const environmentTypes = options.environmentTypes.length === 0 ? (["STAGING", "PRODUCTION"] satisfies RuntimeEnvironmentType[]) : options.environmentTypes; const existingAlertChannel = options.deduplicationKey ? await this._prisma.projectAlertChannel.findFirst({ where: { projectId: project.id, deduplicationKey: options.deduplicationKey, }, }) : undefined; if (existingAlertChannel) { const updated = await this._prisma.projectAlertChannel.update({ where: { id: existingAlertChannel.id }, data: { name: options.name, alertTypes: options.alertTypes, type: options.channel.type, properties: await this.#createProperties(options.channel), environmentTypes, enabled: true, }, }); if (options.alertTypes.includes("ERROR_GROUP")) { await this.#scheduleErrorAlertEvaluation(project.id); } return updated; } const alertChannel = await this._prisma.projectAlertChannel.create({ data: { friendlyId: generateFriendlyId("alert_channel"), name: options.name, alertTypes: options.alertTypes, projectId: project.id, type: options.channel.type, properties: await this.#createProperties(options.channel), enabled: true, deduplicationKey: options.deduplicationKey, userProvidedDeduplicationKey: options.deduplicationKey ? true : false, environmentTypes, }, }); if (options.alertTypes.includes("ERROR_GROUP")) { await this.#scheduleErrorAlertEvaluation(project.id); } return alertChannel; } async #scheduleErrorAlertEvaluation(projectId: string): Promise { await alertsWorker.enqueue({ id: `evaluateErrorAlerts:${projectId}`, job: "v3.evaluateErrorAlerts", payload: { projectId, scheduledAt: Date.now(), }, }); } async #createProperties(channel: CreateAlertChannelOptions["channel"]) { switch (channel.type) { case "EMAIL": return { email: channel.email, }; case "WEBHOOK": return { url: channel.url, secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()), version: "v2", }; case "SLACK": return { channelId: channel.channelId, channelName: channel.channelName, integrationId: channel.integrationId, }; } } }