import { type Prisma, type TaskSchedule } from "@trigger.dev/database"; import cronstrue from "cronstrue"; import { nanoid } from "nanoid"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type UpsertSchedule } from "../schedules"; import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSchedule.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { CheckScheduleService } from "./checkSchedule.server"; import { scheduleEngine } from "../scheduleEngine.server"; import { scheduleWhereClause } from "~/models/schedules.server"; export type UpsertTaskScheduleServiceOptions = UpsertSchedule; type InstanceWithEnvironment = Prisma.TaskScheduleInstanceGetPayload<{ include: { environment: { include: { orgMember: { include: { user: true; }; }; }; }; }; }>; export class UpsertTaskScheduleService extends BaseService { public async call(projectId: string, schedule: UpsertTaskScheduleServiceOptions) { //this throws errors if the schedule is invalid const checkSchedule = new CheckScheduleService(this._prisma); await checkSchedule.call(projectId, schedule, schedule.environments); const deduplicationKey = typeof schedule.deduplicationKey === "string" && schedule.deduplicationKey !== "" ? schedule.deduplicationKey : nanoid(24); const existingSchedule = schedule.friendlyId ? await this._prisma.taskSchedule.findFirst({ where: scheduleWhereClause(projectId, schedule.friendlyId), }) : await this._prisma.taskSchedule.findFirst({ where: { projectId, deduplicationKey, }, }); let result; if (existingSchedule) { if (existingSchedule.type === "DECLARATIVE") { throw new ServiceValidationError("Cannot update a declarative schedule"); } result = await this.#updateExistingSchedule(existingSchedule, schedule); } else { result = await this.#createNewSchedule(schedule, projectId, deduplicationKey); } if (!result) { throw new ServiceValidationError("Failed to create or update schedule"); } const { scheduleRecord } = result; const instances = await this._prisma.taskScheduleInstance.findMany({ where: { taskScheduleId: scheduleRecord.id, }, include: { environment: { include: { orgMember: { include: { user: true, }, }, }, }, }, }); return this.#createReturnObject(scheduleRecord, instances); } async #createNewSchedule( options: UpsertTaskScheduleServiceOptions, projectId: string, deduplicationKey: string ) { const scheduleRecord = await this._prisma.taskSchedule.create({ data: { projectId, friendlyId: generateFriendlyId("sched"), taskIdentifier: options.taskIdentifier, deduplicationKey, userProvidedDeduplicationKey: options.deduplicationKey !== undefined && options.deduplicationKey !== "", generatorExpression: options.cron, generatorDescription: cronstrue.toString(options.cron), timezone: options.timezone ?? "UTC", externalId: options.externalId ? options.externalId : undefined, }, }); //create the instances (links to environments) for (const environmentId of options.environments) { const instance = await this._prisma.taskScheduleInstance.create({ data: { taskScheduleId: scheduleRecord.id, environmentId, projectId, }, include: { environment: { include: { orgMember: { include: { user: true, }, }, }, }, }, }); await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id }); } return { scheduleRecord }; } async #updateExistingSchedule( existingSchedule: TaskSchedule, options: UpsertTaskScheduleServiceOptions ) { // find the existing instances const existingInstances = await this._prisma.taskScheduleInstance.findMany({ where: { taskScheduleId: existingSchedule.id, }, include: { environment: { include: { orgMember: { include: { user: true, }, }, }, }, }, }); const scheduleRecord = await this._prisma.taskSchedule.update({ where: { id: existingSchedule.id, }, data: { generatorExpression: options.cron, generatorDescription: cronstrue.toString(options.cron), timezone: options.timezone ?? "UTC", externalId: options.externalId ? options.externalId : null, }, }); const scheduleHasChanged = scheduleRecord.generatorExpression !== existingSchedule.generatorExpression || scheduleRecord.timezone !== existingSchedule.timezone; // create the new instances const newInstances: InstanceWithEnvironment[] = []; const updatingInstances: InstanceWithEnvironment[] = []; for (const environmentId of options.environments) { const existingInstance = existingInstances.find((i) => i.environmentId === environmentId); if (existingInstance) { // Update the existing instance updatingInstances.push(existingInstance); } else { // Create a new instance const instance = await this._prisma.taskScheduleInstance.create({ data: { taskScheduleId: scheduleRecord.id, environmentId, projectId: existingSchedule.projectId, }, include: { environment: { include: { orgMember: { include: { user: true, }, }, }, }, }, }); newInstances.push(instance); } } // find the instances that need to be removed const instancesToDeleted = existingInstances.filter( (i) => !options.environments.includes(i.environmentId) ); // delete the instances no longer selected for (const instance of instancesToDeleted) { await this._prisma.taskScheduleInstance.delete({ where: { id: instance.id, }, }); } for (const instance of newInstances) { // Register the new task schedule instances await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id }); } if (scheduleHasChanged) { for (const instance of updatingInstances) { // Update the existing task schedule instances await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id }); } } return { scheduleRecord }; } #createReturnObject(taskSchedule: TaskSchedule, instances: InstanceWithEnvironment[]) { return { id: taskSchedule.friendlyId, type: taskSchedule.type, task: taskSchedule.taskIdentifier, active: taskSchedule.active, externalId: taskSchedule.externalId, deduplicationKey: taskSchedule.userProvidedDeduplicationKey ? taskSchedule.deduplicationKey : undefined, cron: taskSchedule.generatorExpression, cronDescription: taskSchedule.generatorDescription, timezone: taskSchedule.timezone, nextRun: calculateNextScheduledTimestampFromNow( taskSchedule.generatorExpression, taskSchedule.timezone ), environments: instances.map((instance) => ({ id: instance.environment.id, shortcode: instance.environment.shortcode, type: instance.environment.type, userName: instance.environment.orgMember?.user.displayName ?? instance.environment.orgMember?.user.name ?? undefined, })), }; } }