diff --git a/package.json b/package.json index 4d7bfde..584444a 100644 --- a/package.json +++ b/package.json @@ -43,32 +43,33 @@ "postinstall": "yarn prisma generate" }, "dependencies": { - "handlebars": "^4.7.7", - "express": "^4.18.2", "@aws-sdk/client-s3": "^3.109.0", "@aws-sdk/s3-request-presigner": "^3.109.0", "@interval/sdk": "2.0.0", "@prisma/client": "^5.3.0", - "prisma": "^5.3.0", "@prisma/generator-helper": "^5.3.0", "@trpc/client": "^9.16.0", "@trpc/react": "^9.16.0", "@trpc/server": "^9.16.0", "@workos-inc/node": "^2.20.0", "commander": "^11.1.0", + "croner": "^8.0.0", "cross-fetch": "^3.1.5", "devalue": "^2.0.1", + "dotenv": "^16.3.1", "ee-ts": "^1.0.2", "email-templates": "^8.0.8", "evt": "^2.4.10", + "express": "^4.18.2", "generate-password": "^1.7.0", "glob": "^8.0.3", + "handlebars": "^4.7.7", "iron-session": "^6.0.5", "loglevel": "^1.8.1", "luxon": "^3.0.4", - "node-cron": "^3.0.1", "postmark": "^3.0.14", "preview-email": "^3.0.6", + "prisma": "^5.3.0", "request-ip": "^3.3.0", "sanitize-html": "^2.11.0", "superjson": "^1.7.4", @@ -79,8 +80,7 @@ "uuid": "^9.0.0", "winston": "^3.8.2", "ws": "^8.4.1", - "zod": "^3.13.3", - "dotenv": "^16.3.1" + "zod": "^3.13.3" }, "devDependencies": { "@babel/core": "^7.16.7", @@ -124,7 +124,6 @@ "@types/luxon": "^3.0.2", "@types/mdx": "^2.0.1", "@types/node": "^17.0.18", - "@types/node-cron": "^3.0.1", "@types/papaparse": "^5.3.1", "@types/preview-email": "^3.0.1", "@types/react": "^18.0.23", @@ -175,6 +174,7 @@ "react-loading-skeleton": "^3.1.0", "react-markdown": "^8.0.0", "react-query": "^3.27.0", + "react-router-dom": "6.3.x", "react-scroll-sync": "^0.11.0", "react-select": "^5.2.2", "react-transition-group": "^4.4.2", @@ -196,8 +196,7 @@ "vite": "^3.2.0", "vite-plugin-checker": "^0.6.2", "vite-tsconfig-paths": "^4.0.5", - "xlsx": "^0.18.5", - "react-router-dom": "6.3.x" + "xlsx": "^0.18.5" }, "resolutions": { "ts-node": "^10.9.1" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5683b0d..cc16d7d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -536,6 +536,7 @@ model ActionSchedule { id String @id @default(dbgenerated("nanoid()")) actionId String runnerId String? + once Boolean @default(false) second String minute String @@ -543,6 +544,7 @@ model ActionSchedule { dayOfMonth String month String dayOfWeek String + date String? timeZoneName String notifyOnSuccess Boolean @default(false) diff --git a/src/components/ActionSettings/Schedule.tsx b/src/components/ActionSettings/Schedule.tsx index 2e67d10..3df07bc 100644 --- a/src/components/ActionSettings/Schedule.tsx +++ b/src/components/ActionSettings/Schedule.tsx @@ -175,7 +175,10 @@ function ActionSchedule({ className="md:w-[140px]" onChange={e => updateInput({ - schedulePeriod: e.target.value as SchedulePeriod, + schedulePeriod: e.target.value as Exclude< + SchedulePeriod, + 'once' | 'now' + >, }) } value={input.schedulePeriod} @@ -367,11 +370,17 @@ export default function ActionScheduleSettings({ const actionScheduleInputs = useMemo( () => - action.schedules.map(s => ({ - id: s.id, - runnerName: s.runner ? displayName(s.runner) : undefined, - ...toScheduleInput(s), - })), + action.schedules.flatMap(s => { + const input = { + id: s.id, + runnerName: s.runner ? displayName(s.runner) : undefined, + ...toScheduleInput(s), + } + if (input.schedulePeriod === 'once' || input.schedulePeriod === 'now') { + return [] + } + return [input] + }), [action.schedules] ) diff --git a/src/server/api/actions.ts b/src/server/api/actions.ts index 649fbc3..16d3b54 100644 --- a/src/server/api/actions.ts +++ b/src/server/api/actions.ts @@ -10,9 +10,36 @@ import prisma from '../prisma' import { loginWithApiKey } from '../auth' import { getQueuedActionParams } from '~/utils/queuedActions' import { logger } from '~/server/utils/logger' +import { ALL_TIMEZONES } from '~/utils/timezones' +import { syncActionSchedules } from '../utils/actionSchedule' const router = express.Router() +const SCHEDULE_ACTION = { + inputs: z.object({ + slug: z.string(), + schedulePeriod: z.enum(['now', 'once', 'hour', 'day', 'week', 'month']), + timeZoneName: z.enum(ALL_TIMEZONES).optional(), + seconds: z.number().optional(), + hours: z.number().optional(), + minutes: z.number().optional(), + dayOfWeek: z.number().optional(), + dayOfMonth: z.number().optional(), + date: z.string().optional().nullable(), + runnerId: z.string().optional().nullable(), + notifyOnSuccess: z.boolean().optional(), + }), + returns: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('success'), + }), + z.object({ + type: z.literal('error'), + message: z.string(), + }), + ]), +} + router.post('/enqueue', async (req, res) => { // To ensure correct return type function sendResponse( @@ -194,4 +221,74 @@ router.post('/dequeue', async (req, res) => { }) }) +router.post('/schedule', async (req, res) => { + // To ensure correct return type + function sendResponse( + statusCode: number, + returns: z.input<(typeof SCHEDULE_ACTION)['returns']> + ) { + res.status(statusCode).send(returns) + } + + const apiKey = req.headers.authorization?.split(' ')[1] + + if (!apiKey) { + return sendResponse(401, { + type: 'error', + message: 'No API key provided.', + }) + } + + const auth = await loginWithApiKey(apiKey) + + if (!auth) { + return sendResponse(403, { + type: 'error', + message: 'Invalid API key provided.', + }) + } + + let inputs: z.infer<(typeof SCHEDULE_ACTION)['inputs']> + try { + inputs = SCHEDULE_ACTION.inputs.parse(req.body) + } catch (err) { + return sendResponse(400, { + type: 'error', + message: JSON.stringify(err), + }) + } + + const { slug: actionSlug, ...scheduleInput } = inputs + + const action = await prisma.action.findFirst({ + where: { + slug: inputs.slug, + organizationId: auth.organization.id, + developerId: + auth.apiKey.usageEnvironment === 'DEVELOPMENT' ? auth.user.id : null, + organizationEnvironmentId: auth.apiKey.organizationEnvironmentId, + }, + include: { + schedules: { + where: { + deletedAt: null, + }, + }, + }, + }) + + if (!action) { + return sendResponse(404, { + type: 'error', + message: 'Action not found.', + }) + } + + await syncActionSchedules(action, [scheduleInput]) + + sendResponse(200, { + type: 'success', + }) +}) + export default router diff --git a/src/server/trpc/action.ts b/src/server/trpc/action.ts index 30d8676..3979f56 100644 --- a/src/server/trpc/action.ts +++ b/src/server/trpc/action.ts @@ -587,12 +587,13 @@ export const actionRouter = createRouter() actionScheduleInputs: z.array( z.object({ id: z.string().optional(), // Used on frontend only, here for types - schedulePeriod: z.enum(['hour', 'day', 'week', 'month']), + schedulePeriod: z.enum(['now', 'once', 'hour', 'day', 'week', 'month']), timeZoneName: z.enum(ALL_TIMEZONES).optional(), hours: z.number().int().optional(), minutes: z.number().int().optional(), dayOfWeek: z.number().int().optional(), dayOfMonth: z.number().int().optional(), + date: z.string().nullish(), notifyOnSuccess: z.boolean().optional(), runnerId: z.string().nullish(), }) diff --git a/src/server/utils/actionSchedule.ts b/src/server/utils/actionSchedule.ts index e47107c..7f22e15 100644 --- a/src/server/utils/actionSchedule.ts +++ b/src/server/utils/actionSchedule.ts @@ -1,4 +1,4 @@ -import * as cron from 'node-cron' +import { Cron } from 'croner' import { ActionSchedule } from '@prisma/client' import { CronSchedule, @@ -12,12 +12,16 @@ import { makeApiCall } from './wss' export function isInputValid(input: ScheduleInput): boolean { const schedule = toCronSchedule(input) if (!schedule) return false - - return cron.validate(cronScheduleToString(schedule)) + return isValid(schedule) } export function isValid(schedule: CronSchedule): boolean { - return cron.validate(cronScheduleToString(schedule)) + try { + Cron(cronScheduleToString(schedule), { maxRuns: 0 }) + return true + } catch { + return false + } } export async function syncActionSchedules( diff --git a/src/utils/actionSchedule.ts b/src/utils/actionSchedule.ts index d7fd039..19aad51 100644 --- a/src/utils/actionSchedule.ts +++ b/src/utils/actionSchedule.ts @@ -1,17 +1,20 @@ import { ActionSchedule } from '@prisma/client' import { DAY_NAMES, numberWithOrdinal, timeToDisplayString } from './date' import { TimeZone } from './timezones' +import Cron from 'croner' -export interface CronSchedule { +export type CronSchedule = { + once?: boolean + timeZoneName: string // Also a TimeZone, broad for deserializing convenience + runnerId?: string | null + notifyOnSuccess?: boolean second: string minute: string hour: string dayOfMonth: string month: string dayOfWeek: string - timeZoneName: string // Also a TimeZone, broad for deserializing convenience - runnerId?: string | null - notifyOnSuccess?: boolean + date?: string | null } export function cronSchedulesEqual(a: CronSchedule, b: CronSchedule) { @@ -24,6 +27,8 @@ export function cronSchedulesEqual(a: CronSchedule, b: CronSchedule) { 'dayOfWeek', 'timeZoneName', 'runnerId', + 'once', + 'date', ]) { if (a[propName] !== b[propName]) return false } @@ -32,6 +37,9 @@ export function cronSchedulesEqual(a: CronSchedule, b: CronSchedule) { } export function cronScheduleToString(schedule: CronSchedule): string { + if (schedule.date) { + return schedule.date + } return [ schedule.second, schedule.minute, @@ -42,15 +50,17 @@ export function cronScheduleToString(schedule: CronSchedule): string { ].join(' ') } -export type SchedulePeriod = 'hour' | 'day' | 'week' | 'month' +export type SchedulePeriod = 'now' | 'once' | 'hour' | 'day' | 'week' | 'month' -export interface ScheduleInput { +export type ScheduleInput = { schedulePeriod: SchedulePeriod timeZoneName?: TimeZone + seconds?: number hours?: number minutes?: number dayOfWeek?: number dayOfMonth?: number + date?: string | null runnerId?: string | null notifyOnSuccess?: boolean } @@ -58,7 +68,7 @@ export interface ScheduleInput { export function actionScheduleToDescriptiveString( schedule: ActionSchedule ): string { - const { minute, hour, dayOfMonth, dayOfWeek, timeZoneName } = schedule + const { minute, hour, dayOfMonth, dayOfWeek, timeZoneName, once } = schedule const timeString = timeToDisplayString( Number(hour), @@ -66,6 +76,13 @@ export function actionScheduleToDescriptiveString( timeZoneName ) + if (once) { + const cron = new Cron(cronScheduleToString(schedule), { maxRuns: 1 }) + const nextRun = cron.nextRun() + if (!nextRun) return 'never' + return `once at ${nextRun.toLocaleString()}` + } + if (dayOfMonth !== '*') { return `every month on the ${numberWithOrdinal( Number(dayOfMonth) @@ -87,18 +104,26 @@ export function actionScheduleToDescriptiveString( * This assumes well-formed simple cron schedules generated by toCronSchedule below. */ export function toScheduleInput(schedule: CronSchedule): ScheduleInput { + const seconds = Number(schedule.second) const hours = Number(schedule.hour) const minutes = Number(schedule.minute) const dayOfWeek = Number(schedule.dayOfWeek) const dayOfMonth = Number(schedule.dayOfMonth) - const schedulePeriod: SchedulePeriod = !Number.isNaN(dayOfMonth) - ? 'month' - : !Number.isNaN(dayOfWeek) - ? 'week' - : !Number.isNaN(hours) - ? 'day' - : 'hour' + let schedulePeriod: SchedulePeriod + if (schedule.date) { + schedulePeriod = 'once' + } else if (schedule.once) { + schedulePeriod = Number.isNaN(seconds) ? 'now' : 'once' + } else { + schedulePeriod = !Number.isNaN(dayOfMonth) + ? 'month' + : !Number.isNaN(dayOfWeek) + ? 'week' + : !Number.isNaN(hours) + ? 'day' + : 'hour' + } const common = { schedulePeriod, @@ -108,17 +133,35 @@ export function toScheduleInput(schedule: CronSchedule): ScheduleInput { } switch (schedulePeriod) { + case 'once': + if ('date' in schedule) { + return { + ...common, + date: schedule.date, + } + } + return { + ...common, + seconds, + minutes, + hours, + dayOfWeek, + dayOfMonth, + } + case 'now': case 'hour': return common case 'day': return { ...common, + seconds, minutes, hours, } case 'week': return { ...common, + seconds, minutes, hours, dayOfWeek, @@ -126,6 +169,7 @@ export function toScheduleInput(schedule: CronSchedule): ScheduleInput { case 'month': return { ...common, + seconds, minutes, hours, dayOfMonth, @@ -137,15 +181,45 @@ export function toCronSchedule({ schedulePeriod, timeZoneName = 'UTC', hours, + seconds, minutes, dayOfWeek, dayOfMonth, + date, runnerId, notifyOnSuccess, }: ScheduleInput): CronSchedule | undefined { switch (schedulePeriod) { case undefined: return undefined + case 'now': + return { + second: '*', + minute: '*', + hour: '*', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + timeZoneName, + runnerId, + notifyOnSuccess, + once: true, + } + case 'once': { + return { + second: seconds?.toString() ?? '0', + minute: minutes?.toString() ?? '0', + hour: hours?.toString() ?? '0', + dayOfMonth: dayOfMonth?.toString() ?? '0', + month: '0', + dayOfWeek: dayOfWeek?.toString() ?? '0', + date, + timeZoneName, + runnerId, + notifyOnSuccess, + once: true, + } + } case 'hour': { return { second: '0', diff --git a/src/wss/actionSchedule.ts b/src/wss/actionSchedule.ts index fc6e017..9673b39 100644 --- a/src/wss/actionSchedule.ts +++ b/src/wss/actionSchedule.ts @@ -1,4 +1,4 @@ -import * as cron from 'node-cron' +import { Cron } from 'croner' import { ActionSchedule, Prisma } from '@prisma/client' import { CronSchedule, @@ -24,17 +24,54 @@ import { TransactionRunner } from '~/utils/user' * to be refactored in order to support multiple app servers. */ -const tasks = new Map() +const tasks = new Map() export function isInputValid(input: ScheduleInput): boolean { const schedule = toCronSchedule(input) if (!schedule) return false - - return cron.validate(cronScheduleToString(schedule)) + return isValid(schedule) } export function isValid(schedule: CronSchedule): boolean { - return cron.validate(cronScheduleToString(schedule)) + try { + Cron(cronScheduleToString(schedule), { maxRuns: 0 }) + return true + } catch { + return false + } +} + +async function deleteActionSchedule(schedule: ActionSchedule) { + const run = await prisma.actionScheduleRun.findFirst({ + where: { + actionScheduleId: schedule.id, + }, + }) + + if (!run) { + try { + await prisma.actionSchedule.delete({ + where: { + id: schedule.id, + }, + }) + } catch (err) { + logger.error( + 'Failed actually deleting action schedule, will soft delete', + { id: schedule.id } + ) + } + } + + await prisma.actionSchedule.updateMany({ + where: { + id: schedule.id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }) } export async function syncActionSchedules( @@ -51,43 +88,21 @@ export async function syncActionSchedules( for (const existing of existingSchedules) { if ( - existing && - (!actionIsBackgroundable || - newSchedules.every( - newSchedule => !cronSchedulesEqual(existing, newSchedule) - )) + !actionIsBackgroundable || + newSchedules.every( + newSchedule => !cronSchedulesEqual(existing, newSchedule) + ) ) { - stop(existing.id) - const run = await prisma.actionScheduleRun.findFirst({ - where: { - actionScheduleId: existing.id, - }, - }) - - if (!run) { - try { - await prisma.actionSchedule.delete({ - where: { - id: existing.id, - }, - }) - } catch (err) { - logger.error( - 'Failed actually deleting action schedule, will soft delete', - { id: existing.id } - ) + if (existing.once && !existing.deletedAt) { + // Preserve once schedules until they have run + const existingSchedule = cronScheduleToString(existing) + const cron = new Cron(existingSchedule, { maxRuns: 1 }) + if (cron.nextRun()) { + continue } } - - await prisma.actionSchedule.updateMany({ - where: { - id: existing.id, - deletedAt: null, - }, - data: { - deletedAt: new Date(), - }, - }) + stop(existing.id) + await deleteActionSchedule(existing) } } @@ -210,8 +225,12 @@ export function schedule( return } - const task = cron.schedule( + const task = Cron( cronScheduleToString(actionSchedule), + { + maxRuns: actionSchedule.once ? 1 : undefined, + timezone: actionSchedule.timeZoneName, + }, async () => { try { const action = await prisma.action.findUnique({ @@ -343,6 +362,10 @@ export function schedule( }, }) + if (actionSchedule.once) { + await deleteActionSchedule(actionSchedule) + } + return } @@ -433,6 +456,10 @@ export function schedule( }, }) return + } finally { + if (actionSchedule.once) { + await deleteActionSchedule(actionSchedule) + } } } catch (err) { logger.error('Failed spawning ActionScheduleRun', { @@ -440,9 +467,6 @@ export function schedule( actionScheduleId: actionSchedule.id, }) } - }, - { - timezone: actionSchedule.timeZoneName, } ) diff --git a/src/wss/index.ts b/src/wss/index.ts index 283e510..a855e0b 100644 --- a/src/wss/index.ts +++ b/src/wss/index.ts @@ -179,12 +179,13 @@ const syncScheduleBody = z.object({ actionId: z.string(), inputs: z.array( z.object({ - schedulePeriod: z.enum(['hour', 'day', 'week', 'month']), + schedulePeriod: z.enum(['now', 'once', 'hour', 'day', 'week', 'month']), timeZoneName: z.enum(ALL_TIMEZONES).optional(), hours: z.number().optional(), minutes: z.number().optional(), dayOfWeek: z.number().optional(), dayOfMonth: z.number().optional(), + date: z.string().optional().nullable(), runnerId: z.string().optional().nullable(), notifyOnSuccess: z.boolean().optional(), }) diff --git a/yarn.lock b/yarn.lock index d391b8c..d173998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5002,11 +5002,6 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== -"@types/node-cron@^3.0.1": - version "3.0.11" - resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" - integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== - "@types/node-fetch@^2.6.4": version "2.6.9" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.9.tgz#15f529d247f1ede1824f7e7acdaa192d5f28071e" @@ -6712,6 +6707,11 @@ crelt@^1.0.0: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== +croner@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/croner/-/croner-8.0.0.tgz#1652fea11a839e6c7ffe79366932318d19756508" + integrity sha512-NhZ7wV9L0nxbSJKYp6u+OHCytFv4RlCu0O6GsglJIZMpMqmtb0kcNNPS5lv1O8qclhDA1NffLSsGx2ftbWQamw== + cross-fetch@^3.1.5: version "3.1.8" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" @@ -11298,13 +11298,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-cron@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.2.tgz#bb0681342bd2dfb568f28e464031280e7f06bd01" - integrity sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ== - dependencies: - uuid "8.3.2" - node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -14710,7 +14703,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -uuid@8.3.2, uuid@^8.3.2: +uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==