Files
2026-07-13 13:32:57 +08:00

351 lines
10 KiB
TypeScript

import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/database";
import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import {
calculateNextScheduledTimestampFromNow,
previousScheduledTimestamp,
} from "~/v3/utils/calculateNextSchedule.server";
import { BasePresenter } from "./basePresenter.server";
type ScheduleListOptions = {
projectId: string;
environmentId: string;
userId?: string;
pageSize?: number;
} & ScheduleListFilters;
const DEFAULT_PAGE_SIZE = 20;
export type ScheduleListItem = {
id: string;
type: ScheduleType;
friendlyId: string;
taskIdentifier: string;
deduplicationKey: string | null;
userProvidedDeduplicationKey: boolean;
cron: string;
cronDescription: string;
timezone: string;
externalId: string | null;
nextRun: Date;
lastRun: Date | undefined;
active: boolean;
environments: {
id: string;
type: RuntimeEnvironmentType;
userName?: string;
branchName?: string;
}[];
};
export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
export type ScheduleListAppliedFilters = ScheduleList["filters"];
export class ScheduleListPresenter extends BasePresenter {
public async call({
userId,
projectId,
environmentId,
tasks,
search,
page,
type,
pageSize = DEFAULT_PAGE_SIZE,
}: ScheduleListOptions) {
const hasFilters =
type !== undefined || tasks !== undefined || (search !== undefined && search !== "");
const filterType =
type === "declarative" ? "DECLARATIVE" : type === "imperative" ? "IMPERATIVE" : undefined;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
type: true,
slug: true,
branchName: true,
archivedAt: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
const environment = project.environments.find((env) => env.id === environmentId);
if (!environment) {
throw new ServiceValidationError("No matching environment for project", 404);
}
const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
prisma: this._replica,
projectId,
});
// Two platform RPCs in parallel. We derive `limit` from `currentPlan`
// rather than calling `getLimit` separately (which would re-issue
// `client.currentPlan` upstream — same data fetched twice). The
// scheduled-task route awaits this presenter synchronously, so every ms
// here is TTFB.
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
getPlans(),
]);
const planLimit = currentPlan?.v3Subscription?.plan?.limits.schedules?.number;
const limit = typeof planLimit === "number" ? planLimit : 100_000_000;
const extraSchedules = currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0;
const canPurchaseSchedules =
currentPlan?.v3Subscription?.plan?.limits.schedules.canExceed === true;
const maxScheduleQuota = currentPlan?.v3Subscription?.addOns?.schedules?.quota ?? 0;
const planScheduleLimit = limit - extraSchedules;
const schedulePricing = plans?.addOnPricing.schedules ?? null;
const purchaseInfo = {
canPurchaseSchedules,
extraSchedules,
maxScheduleQuota,
planScheduleLimit,
schedulePricing,
};
//get the latest BackgroundWorker
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica);
if (!latestWorker) {
return {
currentPage: 1,
totalPages: 1,
totalCount: 0,
schedules: [],
possibleTasks: [],
hasFilters,
limits: {
used: schedulesCount,
limit,
},
...purchaseInfo,
filters: {
tasks,
search,
},
};
}
//get all possible scheduled tasks
const allIdentifiers = await getTaskIdentifiers(environmentId);
const possibleTasks = allIdentifiers
.filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment)
.map((t) => ({ slug: t.slug }));
//do this here to protect against SQL injection
search = search && search !== "" ? `%${search}%` : undefined;
const totalCount = await this._replica.taskSchedule.count({
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: {
some: {
environmentId,
},
},
type: filterType,
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
});
const rawSchedules = await this._replica.taskSchedule.findMany({
select: {
id: true,
type: true,
friendlyId: true,
taskIdentifier: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
generatorExpression: true,
generatorDescription: true,
timezone: true,
externalId: true,
instances: {
select: {
environmentId: true,
},
},
active: true,
createdAt: true,
updatedAt: true,
},
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: {
some: {
environmentId,
},
},
type: filterType,
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
orderBy: {
createdAt: "desc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
// Approximate "last run" from the cron's previous slot. Skip inactive
// schedules — the cron's previous slot reflects what *would* have
// fired, but a deactivated schedule didn't actually fire there. Skip
// when the cron's previous slot predates `updatedAt`: any config
// change (cron edited, timezone changed, deactivate/reactivate)
// bumps updatedAt, and a slot from before the most recent change
// didn't fire under the current configuration. cron-parser throws
// on malformed expressions, so degrade to undefined per-row rather
// than failing the whole list. UI is best-effort; the runs page is
// the source of truth.
let lastRun: Date | undefined;
if (schedule.active) {
try {
const cronPrev = previousScheduledTimestamp(
schedule.generatorExpression,
schedule.timezone
);
lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined;
} catch {
lastRun = undefined;
}
}
return {
id: schedule.id,
type: schedule.type,
friendlyId: schedule.friendlyId,
taskIdentifier: schedule.taskIdentifier,
deduplicationKey: schedule.deduplicationKey,
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
timezone: schedule.timezone,
active: schedule.active,
externalId: schedule.externalId,
lastRun,
nextRun: calculateNextScheduledTimestampFromNow(
schedule.generatorExpression,
schedule.timezone
),
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
throw new Error(
`Environment not found for TaskScheduleInstance env: ${instance.environmentId}`
);
}
return {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
};
}),
};
});
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
totalCount: totalCount,
schedules,
possibleTasks: possibleTasks.map((task) => task.slug),
hasFilters,
limits: {
used: schedulesCount,
limit,
},
...purchaseInfo,
filters: {
tasks,
search,
},
};
}
}