Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts
T
2026-07-13 13:32:57 +08:00

151 lines
4.3 KiB
TypeScript

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<ProjectAlertChannel> {
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<void> {
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,
};
}
}
}