chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* Tests for schedule reactivate PUT API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
authMockFns,
|
||||
databaseMock,
|
||||
workflowAuthzMockFns,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
import { PUT } from './route'
|
||||
|
||||
function createRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(new URL('http://test/api/schedules/sched-1'), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function createParams(id: string): { params: Promise<{ id: string }> } {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
|
||||
const mockDbUpdate = databaseMock.db.update as ReturnType<typeof vi.fn>
|
||||
|
||||
function mockDbChain(selectResults: unknown[][]) {
|
||||
let selectCallIndex = 0
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => selectResults[selectCallIndex++] || [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
mockDbUpdate.mockImplementation(() => ({
|
||||
set: () => ({
|
||||
where: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Schedule PUT API (Reactivate)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('returns 401 when user is not authenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request Validation', () => {
|
||||
it('returns 400 when action is not a valid enum value', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'invalid-action' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Invalid request body')
|
||||
})
|
||||
|
||||
it('returns 400 when action is missing', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({}), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Invalid request body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule Not Found', () => {
|
||||
it('returns 404 when schedule does not exist', async () => {
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-999'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule not found')
|
||||
})
|
||||
|
||||
it('returns 404 when workflow does not exist for schedule', async () => {
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
message: 'Workflow not found',
|
||||
})
|
||||
mockDbChain([[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], []])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorization', () => {
|
||||
it('returns 403 when user is not workflow owner', async () => {
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
workflow: { id: 'wf-1', workspaceId: null },
|
||||
workspacePermission: null,
|
||||
message:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
|
||||
})
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const data = await res.json()
|
||||
expect(data.error).toContain('Personal workflows are deprecated')
|
||||
})
|
||||
|
||||
it('returns 403 for workspace member with only read permission', async () => {
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
})
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('allows workflow owner to reactivate', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule activated successfully')
|
||||
})
|
||||
|
||||
it('allows workspace member with write permission to reactivate', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('allows workspace admin to reactivate', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule State Handling', () => {
|
||||
it('returns success message when schedule is already active', async () => {
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'active' }],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule is already active')
|
||||
expect(mockDbUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('successfully reactivates disabled schedule', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.message).toBe('Schedule activated successfully')
|
||||
expect(data.nextRunAt).toBeDefined()
|
||||
expect(mockDbUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when schedule has no cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: null,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule has no cron expression')
|
||||
})
|
||||
|
||||
it('returns 400 when schedule has invalid cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: 'invalid-cron',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Schedule has invalid cron expression')
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from stored cron expression (every 5 minutes)', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/5 * * * *',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
const afterCall = Date.now()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt).getTime()
|
||||
|
||||
// nextRunAt should be within 0-5 minutes in the future
|
||||
expect(nextRunAt).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000)
|
||||
// Should align with 5-minute intervals (minute divisible by 5)
|
||||
expect(new Date(nextRunAt).getUTCMinutes() % 5).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from daily cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '30 14 * * *', // 2:30 PM daily
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at 14:30 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCHours()).toBe(14)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(30)
|
||||
expect(nextRunAt.getUTCSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from weekly cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * 1', // Monday at 9:00 AM
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on Monday at 09:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDay()).toBe(1) // Monday
|
||||
expect(nextRunAt.getUTCHours()).toBe(9)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from monthly cron expression', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 10 15 * *', // 15th of month at 10:00 AM
|
||||
timezone: 'UTC',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on the 15th at 10:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDate()).toBe(15)
|
||||
expect(nextRunAt.getUTCHours()).toBe(10)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timezone Handling in Reactivation', () => {
|
||||
it('calculates nextRunAt with America/New_York timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * *', // 9:00 AM Eastern
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
// The exact UTC hour will depend on DST, but it should be 13:00 or 14:00 UTC
|
||||
const utcHour = nextRunAt.getUTCHours()
|
||||
expect([13, 14]).toContain(utcHour) // 9 AM ET = 1-2 PM UTC depending on DST
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt with Asia/Tokyo timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '30 15 * * *', // 3:30 PM Japan Time
|
||||
timezone: 'Asia/Tokyo',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
// 3:30 PM JST (UTC+9) = 6:30 AM UTC
|
||||
expect(nextRunAt.getUTCHours()).toBe(6)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt with Europe/London timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 12 * * 5', // Friday at noon London time
|
||||
timezone: 'Europe/London',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on Friday
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCDay()).toBe(5) // Friday
|
||||
// UTC hour depends on BST/GMT (11:00 or 12:00 UTC)
|
||||
const utcHour = nextRunAt.getUTCHours()
|
||||
expect([11, 12]).toContain(utcHour)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('uses UTC as default timezone when timezone is not set', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 10 * * *', // 10:00 AM
|
||||
timezone: null,
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at 10:00 UTC
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCHours()).toBe(10)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('handles minutely schedules with timezone correctly', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '*/10 * * * *', // Every 10 minutes
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date within the next 10 minutes
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000)
|
||||
// Should align with 10-minute intervals
|
||||
expect(nextRunAt.getUTCMinutes() % 10).toBe(0)
|
||||
})
|
||||
|
||||
it('handles hourly schedules with timezone correctly', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '15 * * * *', // At minute 15 of every hour
|
||||
timezone: 'America/Chicago',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date at minute 15
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(15)
|
||||
expect(nextRunAt.getUTCSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it('handles custom cron expressions with complex patterns and timezone', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
id: 'sched-1',
|
||||
workflowId: 'wf-1',
|
||||
status: 'disabled',
|
||||
cronExpression: '0 9 * * 1-5', // Weekdays at 9 AM
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
],
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
])
|
||||
|
||||
const beforeCall = Date.now()
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
const nextRunAt = new Date(data.nextRunAt)
|
||||
|
||||
// Should be a future date on a weekday (1-5)
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
const dayOfWeek = nextRunAt.getUTCDay()
|
||||
expect([1, 2, 3, 4, 5]).toContain(dayOfWeek)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('returns 500 when database operation fails', async () => {
|
||||
mockDbSelect.mockImplementation(() => {
|
||||
throw new Error('Database connection failed')
|
||||
})
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Failed to update schedule')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getScheduleByIdContract, updateScheduleContract } from '@/lib/api/contracts/schedules'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
performDeleteJob,
|
||||
performExcludeOccurrence,
|
||||
performUpdateJob,
|
||||
} from '@/lib/workflows/schedules/orchestration'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ScheduleAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type ScheduleRow = typeof workflowSchedule.$inferSelect
|
||||
|
||||
async function fetchAndAuthorize(
|
||||
requestId: string,
|
||||
scheduleId: string,
|
||||
userId: string,
|
||||
action: 'read' | 'write'
|
||||
): Promise<{ schedule: ScheduleRow; workspaceId: string | null } | NextResponse> {
|
||||
const [schedule] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!schedule) {
|
||||
logger.warn(`[${requestId}] Schedule not found: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (schedule.sourceType === 'job') {
|
||||
if (!schedule.sourceWorkspaceId) {
|
||||
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
|
||||
}
|
||||
const permission = await verifyWorkspaceMembership(userId, schedule.sourceWorkspaceId)
|
||||
const canWrite = permission === 'admin' || permission === 'write'
|
||||
if (!permission || (action === 'write' && !canWrite)) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
return { schedule, workspaceId: schedule.sourceWorkspaceId }
|
||||
}
|
||||
|
||||
if (!schedule.workflowId) {
|
||||
logger.warn(`[${requestId}] Schedule has no workflow: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Schedule has no associated workflow' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: schedule.workflowId,
|
||||
userId,
|
||||
action,
|
||||
})
|
||||
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User not authorized to modify schedule: ${scheduleId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Not authorized to modify this schedule' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
return { schedule, workspaceId: authorization.workflow.workspaceId ?? null }
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getScheduleByIdContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid request' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: scheduleId } = parsed.data.params
|
||||
|
||||
// fetchAndAuthorize already loads the full row (and 404s if missing), so
|
||||
// return it directly — no second query.
|
||||
const authResult = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'read')
|
||||
if (authResult instanceof NextResponse) return authResult
|
||||
|
||||
return NextResponse.json({ schedule: authResult.schedule })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to get schedule`, { error })
|
||||
return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateScheduleContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid request body' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: scheduleId } = parsed.data.params
|
||||
const validatedBody = parsed.data.body
|
||||
|
||||
const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write')
|
||||
if (result instanceof NextResponse) return result
|
||||
const { schedule, workspaceId } = result
|
||||
if (schedule.workflowId) {
|
||||
await assertWorkflowMutable(schedule.workflowId)
|
||||
}
|
||||
|
||||
const { action } = validatedBody
|
||||
|
||||
if (action === 'disable') {
|
||||
if (schedule.status === 'disabled') {
|
||||
return NextResponse.json({ message: 'Schedule is already disabled' })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set({ status: 'disabled', nextRunAt: null, lastQueuedAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: scheduleId,
|
||||
resourceName: schedule.jobTitle ?? undefined,
|
||||
description: `Disabled schedule "${schedule.jobTitle ?? scheduleId}"`,
|
||||
metadata: {
|
||||
operation: 'disable',
|
||||
sourceType: schedule.sourceType,
|
||||
previousStatus: schedule.status,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Schedule disabled successfully' })
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
if (schedule.sourceType !== 'job') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only standalone job schedules can be edited' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updateResult = await performUpdateJob({
|
||||
jobId: scheduleId,
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
title: validatedBody.title,
|
||||
prompt: validatedBody.prompt,
|
||||
timezone: validatedBody.timezone,
|
||||
lifecycle: validatedBody.lifecycle,
|
||||
maxRuns: validatedBody.maxRuns,
|
||||
cronExpression: validatedBody.cronExpression,
|
||||
time: validatedBody.time,
|
||||
endsAt: validatedBody.endsAt,
|
||||
contexts: validatedBody.contexts,
|
||||
request,
|
||||
})
|
||||
if (!updateResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: updateResult.error || 'Failed to update schedule' },
|
||||
{ status: updateResult.errorCode === 'validation' ? 400 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
|
||||
|
||||
return NextResponse.json({ message: 'Schedule updated successfully' })
|
||||
}
|
||||
|
||||
if (action === 'exclude_occurrence') {
|
||||
if (schedule.sourceType !== 'job') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only standalone job schedules have occurrences' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
const excludeResult = await performExcludeOccurrence({
|
||||
jobId: scheduleId,
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
occurrence: validatedBody.occurrence,
|
||||
request,
|
||||
})
|
||||
if (!excludeResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: excludeResult.error || 'Failed to delete occurrence' },
|
||||
{
|
||||
status:
|
||||
excludeResult.errorCode === 'not_found'
|
||||
? 404
|
||||
: excludeResult.errorCode === 'validation'
|
||||
? 400
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Excluded occurrence on job schedule: ${scheduleId}`)
|
||||
|
||||
return NextResponse.json({ message: 'Occurrence deleted successfully' })
|
||||
}
|
||||
|
||||
// reactivate
|
||||
if (schedule.status === 'active') {
|
||||
return NextResponse.json({ message: 'Schedule is already active' })
|
||||
}
|
||||
|
||||
if (!schedule.cronExpression) {
|
||||
logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC')
|
||||
if (!cronResult.isValid || !cronResult.nextRun) {
|
||||
logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const nextRunAt = cronResult.nextRun
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set({ status: 'active', failedCount: 0, infraRetryCount: 0, updatedAt: now, nextRunAt })
|
||||
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: scheduleId,
|
||||
resourceName: schedule.jobTitle ?? undefined,
|
||||
description: `Reactivated schedule "${schedule.jobTitle ?? scheduleId}"`,
|
||||
metadata: {
|
||||
operation: 'reactivate',
|
||||
sourceType: schedule.sourceType,
|
||||
cronExpression: schedule.cronExpression,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const { id: scheduleId } = await params
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule delete attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write')
|
||||
if (result instanceof NextResponse) return result
|
||||
const { schedule, workspaceId } = result
|
||||
|
||||
if (schedule.sourceType === 'job') {
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
|
||||
}
|
||||
const deleteResult = await performDeleteJob({
|
||||
jobId: scheduleId,
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
request,
|
||||
})
|
||||
if (!deleteResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: deleteResult.error || 'Failed to delete schedule' },
|
||||
{ status: deleteResult.errorCode === 'not_found' ? 404 : 500 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ message: 'Schedule deleted successfully' })
|
||||
}
|
||||
|
||||
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId))
|
||||
|
||||
logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.SCHEDULE_DELETED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: scheduleId,
|
||||
resourceName: schedule.jobTitle ?? undefined,
|
||||
description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} "${schedule.jobTitle ?? scheduleId}"`,
|
||||
metadata: {
|
||||
sourceType: schedule.sourceType,
|
||||
cronExpression: schedule.cronExpression,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_deleted',
|
||||
{ workspace_id: workspaceId ?? '' },
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ message: 'Schedule deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,874 @@
|
||||
/**
|
||||
* Integration tests for scheduled workflow execution API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, requestUtilsMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const orderByLimitMock = vi.fn()
|
||||
|
||||
const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockReleaseScheduleLock,
|
||||
mockFeatureFlags,
|
||||
mockEnqueue,
|
||||
mockGetJob,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
mockCancelJob,
|
||||
mockShouldExecuteInline,
|
||||
} = vi.hoisted(() => ({
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockReleaseScheduleLock: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
},
|
||||
mockEnqueue: vi.fn().mockResolvedValue('job-id-1'),
|
||||
mockGetJob: vi.fn().mockResolvedValue(null),
|
||||
mockStartJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockCompleteJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockMarkJobFailed: vi.fn().mockResolvedValue(undefined),
|
||||
mockCancelJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({
|
||||
verifyCronAuth: mockVerifyCronAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/background/schedule-execution', () => ({
|
||||
executeScheduleJob: mockExecuteScheduleJob,
|
||||
executeJobInline: mockExecuteJobInline,
|
||||
releaseScheduleLock: mockReleaseScheduleLock,
|
||||
buildScheduleFailureUpdate: (now: Date, nextRunAt: Date | null) => ({
|
||||
updatedAt: now,
|
||||
lastQueuedAt: null,
|
||||
nextRunAt,
|
||||
failedCount: { type: 'sql' },
|
||||
lastFailedAt: now,
|
||||
status: { type: 'sql' },
|
||||
infraRetryCount: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => mockFeatureFlags)
|
||||
|
||||
vi.mock('@/lib/core/async-jobs', () => ({
|
||||
getJobQueue: vi.fn().mockResolvedValue({
|
||||
enqueue: mockEnqueue,
|
||||
getJob: mockGetJob,
|
||||
startJob: mockStartJob,
|
||||
completeJob: mockCompleteJob,
|
||||
markJobFailed: mockMarkJobFailed,
|
||||
cancelJob: mockCancelJob,
|
||||
}),
|
||||
shouldExecuteInline: mockShouldExecuteInline,
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })),
|
||||
lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })),
|
||||
lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })),
|
||||
inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values, type: 'inArray' })),
|
||||
not: vi.fn((condition: unknown) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
asc: vi.fn((field: unknown) => ({ type: 'asc', field })),
|
||||
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
...dbChainMock,
|
||||
workflowSchedule: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
blockId: 'blockId',
|
||||
cronExpression: 'cronExpression',
|
||||
lastRanAt: 'lastRanAt',
|
||||
failedCount: 'failedCount',
|
||||
infraRetryCount: 'infraRetryCount',
|
||||
status: 'status',
|
||||
timezone: 'timezone',
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
sourceType: 'sourceType',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
asyncJobs: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
payload: 'payload',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
runAt: 'runAt',
|
||||
startedAt: 'startedAt',
|
||||
completedAt: 'completedAt',
|
||||
attempts: 'attempts',
|
||||
maxAttempts: 'maxAttempts',
|
||||
error: 'error',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn(() => 'schedule-execution-1'),
|
||||
generateShortId: vi.fn(() => 'mock-short-id'),
|
||||
isValidUuid: vi.fn((v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/random', () => ({
|
||||
randomInt: vi.fn(() => 0),
|
||||
}))
|
||||
|
||||
import { GET, runScheduleTick } from './route'
|
||||
|
||||
const SINGLE_SCHEDULE = [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
blockId: null,
|
||||
cronExpression: null,
|
||||
lastRanAt: null,
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
timezone: 'UTC',
|
||||
nextRunAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
lastQueuedAt: undefined,
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
]
|
||||
|
||||
const MULTIPLE_SCHEDULES = [
|
||||
...SINGLE_SCHEDULE,
|
||||
{
|
||||
id: 'schedule-2',
|
||||
workflowId: 'workflow-2',
|
||||
blockId: null,
|
||||
cronExpression: null,
|
||||
lastRanAt: null,
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
timezone: 'UTC',
|
||||
nextRunAt: new Date('2025-01-01T01:00:00.000Z'),
|
||||
lastQueuedAt: undefined,
|
||||
workspaceId: 'workspace-2',
|
||||
},
|
||||
]
|
||||
|
||||
const SINGLE_CLAIMED_SCHEDULE_ROWS = [{ id: 'schedule-1', workspaceId: 'workspace-1' }]
|
||||
|
||||
const SINGLE_JOB = [
|
||||
{
|
||||
id: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
timezone: 'UTC',
|
||||
lastQueuedAt: undefined,
|
||||
sourceType: 'job',
|
||||
},
|
||||
]
|
||||
|
||||
function conditionContains(
|
||||
condition: unknown,
|
||||
predicate: (entry: Record<string, unknown>) => boolean
|
||||
): boolean {
|
||||
if (!condition || typeof condition !== 'object') return false
|
||||
if (Array.isArray(condition)) {
|
||||
return condition.some((item) => conditionContains(item, predicate))
|
||||
}
|
||||
|
||||
const entry = condition as Record<string, unknown>
|
||||
if (predicate(entry)) return true
|
||||
|
||||
return Object.values(entry).some((value) => conditionContains(value, predicate))
|
||||
}
|
||||
|
||||
function isActiveScheduleExecutionCountCondition(condition: unknown): boolean {
|
||||
return (
|
||||
conditionContains(
|
||||
condition,
|
||||
(entry) =>
|
||||
entry.type === 'eq' && entry.field === 'type' && entry.value === 'schedule-execution'
|
||||
) &&
|
||||
conditionContains(
|
||||
condition,
|
||||
(entry) => entry.type === 'eq' && entry.field === 'status' && entry.value === 'processing'
|
||||
) &&
|
||||
!conditionContains(condition, (entry) => entry.type === 'or')
|
||||
)
|
||||
}
|
||||
|
||||
function mockProcessingCounts(...counts: number[]) {
|
||||
const defaultWhere = dbChainMockFns.where.getMockImplementation()
|
||||
if (!defaultWhere) throw new Error('Expected default where mock implementation')
|
||||
let index = 0
|
||||
|
||||
dbChainMockFns.where.mockImplementation((condition: unknown) => {
|
||||
if (isActiveScheduleExecutionCountCondition(condition) && index < counts.length) {
|
||||
const count = counts[index]
|
||||
index += 1
|
||||
return Promise.resolve([{ count }]) as ReturnType<typeof dbChainMockFns.where>
|
||||
}
|
||||
|
||||
return defaultWhere(condition)
|
||||
})
|
||||
}
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
const mockHeaders = new Map([
|
||||
['authorization', 'Bearer test-cron-secret'],
|
||||
['content-type', 'application/json'],
|
||||
])
|
||||
|
||||
return {
|
||||
headers: {
|
||||
get: (key: string) => mockHeaders.get(key.toLowerCase()) || null,
|
||||
},
|
||||
url: 'http://localhost:3000/api/schedules/execute',
|
||||
} as NextRequest
|
||||
}
|
||||
|
||||
describe('Scheduled Workflow Execution API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbChainMockFns.limit.mockReset()
|
||||
dbChainMockFns.returning.mockReset()
|
||||
dbChainMockFns.execute.mockReset()
|
||||
orderByLimitMock.mockReset()
|
||||
orderByLimitMock.mockResolvedValue([])
|
||||
resetDbChainMock()
|
||||
dbChainMockFns.orderBy.mockReturnValue({ limit: orderByLimitMock } as never)
|
||||
dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never)
|
||||
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id')
|
||||
mockFeatureFlags.isTriggerDevEnabled = false
|
||||
mockFeatureFlags.isHosted = false
|
||||
mockFeatureFlags.isProd = false
|
||||
mockFeatureFlags.isDev = true
|
||||
mockShouldExecuteInline.mockReturnValue(false)
|
||||
mockEnqueue.mockReset()
|
||||
mockEnqueue.mockResolvedValue('job-id-1')
|
||||
mockGetJob.mockReset()
|
||||
mockGetJob.mockResolvedValue(null)
|
||||
mockStartJob.mockReset()
|
||||
mockStartJob.mockResolvedValue(undefined)
|
||||
mockCompleteJob.mockReset()
|
||||
mockCompleteJob.mockResolvedValue(undefined)
|
||||
mockMarkJobFailed.mockReset()
|
||||
mockMarkJobFailed.mockResolvedValue(undefined)
|
||||
mockCancelJob.mockReset()
|
||||
mockCancelJob.mockResolvedValue(undefined)
|
||||
mockExecuteScheduleJob.mockReset()
|
||||
mockExecuteScheduleJob.mockResolvedValue(undefined)
|
||||
mockExecuteJobInline.mockReset()
|
||||
mockExecuteJobInline.mockResolvedValue(undefined)
|
||||
mockReleaseScheduleLock.mockReset()
|
||||
mockReleaseScheduleLock.mockResolvedValue(undefined)
|
||||
dbChainMockFns.returning.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should execute scheduled workflows with Trigger.dev disabled', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should queue schedules to Trigger.dev when enabled', async () => {
|
||||
mockFeatureFlags.isTriggerDevEnabled = true
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle case with no due schedules', async () => {
|
||||
dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce([])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should execute multiple schedules in parallel', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'schedule-1', workspaceId: 'workspace-1' },
|
||||
{ id: 'schedule-2', workspaceId: 'workspace-2' },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(2)
|
||||
})
|
||||
|
||||
it('should execute mothership jobs inline', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'job-1' }])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(SINGLE_JOB)
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockExecuteJobInline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
now: expect.any(String),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should enqueue schedule with correlation metadata via job queue', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'schedule-execution',
|
||||
expect.objectContaining({
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
|
||||
metadata: expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
correlation: expect.objectContaining({
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
source: 'schedule',
|
||||
workflowId: 'workflow-1',
|
||||
scheduleId: 'schedule-1',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(mockEnqueue.mock.calls[0][2].concurrencyKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('executes database fallback schedules through durable async job rows', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning
|
||||
.mockReturnValueOnce(SINGLE_SCHEDULE)
|
||||
.mockResolvedValueOnce([{ id: 'job-id-1' }])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'schedule-execution',
|
||||
expect.objectContaining({ scheduleId: 'schedule-1' }),
|
||||
expect.objectContaining({
|
||||
jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
|
||||
metadata: expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(mockStartJob).not.toHaveBeenCalled()
|
||||
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scheduleId: 'schedule-1' })
|
||||
)
|
||||
expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null)
|
||||
})
|
||||
|
||||
it('releases database fallback claims when the global concurrency cap is full', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
mockProcessingCounts(0, 0, 50)
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning
|
||||
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: claimedAt }])
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).toHaveBeenCalled()
|
||||
expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
|
||||
expect(mockCompleteJob).not.toHaveBeenCalled()
|
||||
expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers stale database fallback processing jobs before resuming them', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const staleStartedAt = new Date('2024-12-31T00:00:00.000Z')
|
||||
mockProcessingCounts(0, 0)
|
||||
mockGetJob
|
||||
.mockResolvedValueOnce({
|
||||
id: 'job-id-1',
|
||||
status: 'processing',
|
||||
startedAt: staleStartedAt,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'job-id-1',
|
||||
status: 'pending',
|
||||
})
|
||||
orderByLimitMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'job-id-1',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
])
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning
|
||||
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: new Date('2025-01-01') }])
|
||||
.mockResolvedValueOnce([{ id: 'job-id-1' }])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scheduleId: 'schedule-1' })
|
||||
)
|
||||
expect(mockCompleteJob).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
|
||||
null
|
||||
)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'pending',
|
||||
startedAt: null,
|
||||
error: expect.stringContaining('stale schedule execution processing lease'),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes pending database fallback jobs without waiting for a stale schedule claim', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
mockProcessingCounts(0, 0, 0)
|
||||
orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'pending-job-id',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: claimedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
])
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([{ lastQueuedAt: claimedAt }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'pending-job-id' }])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(1)
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: claimedAt.toISOString(),
|
||||
})
|
||||
)
|
||||
expect(mockCompleteJob).toHaveBeenCalledWith('pending-job-id', null)
|
||||
})
|
||||
|
||||
it('completes stale pending database fallback jobs whose schedule claim was already released', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
mockProcessingCounts(0, 0)
|
||||
orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'stale-pending-job-id',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: claimedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
])
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([{ lastQueuedAt: null }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
|
||||
expect(mockCompleteJob).toHaveBeenCalledWith(
|
||||
'stale-pending-job-id',
|
||||
expect.objectContaining({
|
||||
skipped: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails exhausted stale database fallback jobs instead of retrying forever', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
mockProcessingCounts(0, 0)
|
||||
orderByLimitMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'exhausted-job-id',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: claimedAt.toISOString(),
|
||||
},
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
])
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: expect.stringContaining('exhausted retry attempts'),
|
||||
})
|
||||
)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: null,
|
||||
lastFailedAt: expect.any(Date),
|
||||
nextRunAt: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('defers schedule claims when retryable lookup infrastructure fails before enqueue', async () => {
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: claimedAt,
|
||||
}
|
||||
mockGetJob.mockRejectedValueOnce(
|
||||
Object.assign(new Error('queue lookup failed'), { code: 'ECONNRESET' })
|
||||
)
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: null,
|
||||
nextRunAt: expect.any(Date),
|
||||
infraRetryCount: 1,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('marks schedules failed when non-retryable setup errors happen before enqueue', async () => {
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: claimedAt,
|
||||
}
|
||||
mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant'))
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: null,
|
||||
lastFailedAt: expect.any(Date),
|
||||
nextRunAt: expect.any(Date),
|
||||
infraRetryCount: 0,
|
||||
})
|
||||
)
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
infraRetryCount: 1,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one backend mode decision for slot accounting and schedule processing', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning
|
||||
.mockReturnValueOnce(SINGLE_SCHEDULE)
|
||||
.mockResolvedValueOnce([{ id: 'job-id-1' }])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1)
|
||||
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scheduleId: 'schedule-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the original claim token when an active durable job owns the occurrence', async () => {
|
||||
const originalClaim = new Date()
|
||||
const staleReclaim = new Date(originalClaim.getTime() + 60_000)
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: staleReclaim,
|
||||
}
|
||||
mockGetJob.mockResolvedValueOnce({
|
||||
id: 'job-id-1',
|
||||
status: 'processing',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: originalClaim.toISOString(),
|
||||
},
|
||||
})
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: originalClaim,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore stale database fallback claims for fresh processing jobs', async () => {
|
||||
mockShouldExecuteInline.mockReturnValue(true)
|
||||
const originalClaim = new Date('2024-01-01T00:00:00.000Z')
|
||||
const staleReclaim = new Date()
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: staleReclaim,
|
||||
}
|
||||
mockGetJob
|
||||
.mockResolvedValueOnce({
|
||||
id: 'job-id-1',
|
||||
status: 'processing',
|
||||
startedAt: new Date(),
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: originalClaim.toISOString(),
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'job-id-1',
|
||||
status: 'processing',
|
||||
startedAt: new Date(),
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: originalClaim.toISOString(),
|
||||
},
|
||||
})
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([schedule])
|
||||
.mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: originalClaim,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the original claim token when Trigger.dev returns an idempotent existing run', async () => {
|
||||
const originalClaim = new Date()
|
||||
const staleReclaim = new Date(originalClaim.getTime() + 60_000)
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: staleReclaim,
|
||||
}
|
||||
mockEnqueue.mockResolvedValueOnce('trigger-run-id')
|
||||
mockGetJob.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: 'trigger-run-id',
|
||||
status: 'processing',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: originalClaim.toISOString(),
|
||||
},
|
||||
})
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockEnqueue).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastQueuedAt: originalClaim,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels stale Trigger.dev runs instead of restoring an expired claim forever', async () => {
|
||||
const originalClaim = new Date('2024-01-01T00:00:00.000Z')
|
||||
const staleReclaim = new Date()
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: staleReclaim,
|
||||
}
|
||||
mockEnqueue.mockResolvedValueOnce('trigger-run-id')
|
||||
mockGetJob.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: 'trigger-run-id',
|
||||
status: 'processing',
|
||||
payload: {
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
now: originalClaim.toISOString(),
|
||||
},
|
||||
})
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockCancelJob).toHaveBeenCalledWith('trigger-run-id')
|
||||
expect(mockReleaseScheduleLock).toHaveBeenCalledWith(
|
||||
'schedule-1',
|
||||
'test-request-id',
|
||||
expect.any(Date),
|
||||
expect.stringContaining('cancelling stale queued schedule execution job'),
|
||||
undefined,
|
||||
{ expectedLastQueuedAt: staleReclaim }
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds workflow schedule claims to the configured enqueue budget', async () => {
|
||||
const claimedIds = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `schedule-${index}`,
|
||||
workspaceId: `workspace-${index}`,
|
||||
}))
|
||||
const claimedSchedules = claimedIds.map((row, index) => ({
|
||||
id: row.id,
|
||||
workflowId: `workflow-${index}`,
|
||||
blockId: null,
|
||||
cronExpression: null,
|
||||
lastRanAt: null,
|
||||
failedCount: 0,
|
||||
infraRetryCount: 0,
|
||||
timezone: 'UTC',
|
||||
nextRunAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
lastQueuedAt: undefined,
|
||||
workspaceId: row.workspaceId,
|
||||
}))
|
||||
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(claimedIds).mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce(claimedSchedules).mockReturnValueOnce([])
|
||||
|
||||
const result = await runScheduleTick('test-request-id')
|
||||
|
||||
expect(result.processedCount).toBe(100)
|
||||
expect(dbChainMockFns.limit).toHaveBeenCalledWith(100)
|
||||
expect(mockEnqueue).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('guards route-side stale release updates with the claimed occurrence', async () => {
|
||||
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
|
||||
const schedule = {
|
||||
...SINGLE_SCHEDULE[0],
|
||||
lastQueuedAt: claimedAt,
|
||||
}
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
|
||||
.mockResolvedValueOnce([])
|
||||
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
|
||||
mockGetJob.mockResolvedValueOnce({ id: 'job-id-1', status: 'completed' })
|
||||
|
||||
await runScheduleTick('test-request-id')
|
||||
expect(mockReleaseScheduleLock).toHaveBeenCalledWith(
|
||||
'schedule-1',
|
||||
'test-request-id',
|
||||
expect.any(Date),
|
||||
expect.stringContaining('finished job'),
|
||||
null,
|
||||
{ expectedLastQueuedAt: claimedAt }
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET handler (fire-and-forget)', () => {
|
||||
it('returns the auth error when cron auth fails', async () => {
|
||||
mockVerifyCronAuth.mockReturnValueOnce(
|
||||
NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('acknowledges immediately with 202 and starts the tick in the background', async () => {
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
const data = await response.json()
|
||||
expect(data).toMatchObject({ status: 'started' })
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Tests for schedule GET API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, databaseMock, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/schedules/route'
|
||||
|
||||
function createRequest(url: string): NextRequest {
|
||||
return new NextRequest(new URL(url), { method: 'GET' })
|
||||
}
|
||||
|
||||
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
|
||||
|
||||
function mockDbChain(results: any[]) {
|
||||
let callIndex = 0
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => results[callIndex++] || [],
|
||||
}),
|
||||
leftJoin: () => ({
|
||||
where: () => ({
|
||||
limit: () => results[callIndex++] || [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Schedule GET API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns schedule data for authorized user', async () => {
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
schedule: {
|
||||
id: 'sched-1',
|
||||
cronExpression: '0 9 * * *',
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.schedule.cronExpression).toBe('0 9 * * *')
|
||||
expect(data.isDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when no schedule exists', async () => {
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.schedule).toBeNull()
|
||||
})
|
||||
|
||||
it('requires authentication', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('requires workflowId parameter', async () => {
|
||||
const res = await GET(createRequest('http://test/api/schedules'))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for non-existent workflow', async () => {
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
})
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('denies access for unauthorized user', async () => {
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
mockDbChain([[{ userId: 'other-user', workspaceId: null }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('allows workspace members to view', async () => {
|
||||
mockDbChain([[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('indicates disabled schedule with failures', async () => {
|
||||
mockDbChain([[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.isDisabled).toBe(true)
|
||||
expect(data.hasFailures).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createScheduleContract, scheduleQuerySchema } from '@/lib/api/contracts/schedules'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performCreateJob } from '@/lib/workflows/schedules/orchestration'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ScheduledAPI')
|
||||
|
||||
/**
|
||||
* Get schedule information for a workflow, or all schedules for a workspace.
|
||||
*
|
||||
* Query params (choose one):
|
||||
* - workflowId + optional blockId → single schedule for one workflow
|
||||
* - workspaceId → all schedules across the workspace
|
||||
*/
|
||||
export const GET = withRouteHandler(async (req: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
const url = new URL(req.url)
|
||||
const queryValidation = scheduleQuerySchema.safeParse(
|
||||
Object.fromEntries(url.searchParams.entries())
|
||||
)
|
||||
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
|
||||
const { workflowId, workspaceId, blockId } = queryValidation.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule query attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
return handleWorkspaceSchedules(requestId, session.user.id, workspaceId)
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing workflowId or workspaceId parameter' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
action: 'read',
|
||||
})
|
||||
|
||||
if (!authorization.workflow) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Not authorized to view this workflow' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
|
||||
|
||||
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
|
||||
if (blockId) {
|
||||
conditions.push(eq(workflowSchedule.blockId, blockId))
|
||||
}
|
||||
|
||||
const schedule = await db
|
||||
.select({ schedule: workflowSchedule })
|
||||
.from(workflowSchedule)
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...conditions,
|
||||
isNull(workflowSchedule.archivedAt),
|
||||
or(
|
||||
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Cache-Control', 'no-store, max-age=0')
|
||||
|
||||
if (schedule.length === 0) {
|
||||
return NextResponse.json({ schedule: null }, { headers })
|
||||
}
|
||||
|
||||
const scheduleData = schedule[0].schedule
|
||||
const isDisabled = scheduleData.status === 'disabled'
|
||||
const hasFailures = scheduleData.failedCount > 0
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
schedule: scheduleData,
|
||||
isDisabled,
|
||||
hasFailures,
|
||||
canBeReactivated: isDisabled,
|
||||
},
|
||||
{ headers }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error retrieving workflow schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
async function handleWorkspaceSchedules(requestId: string, userId: string, workspaceId: string) {
|
||||
const hasPermission = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Getting all schedules for workspace ${workspaceId}`)
|
||||
|
||||
const [workflowRows, jobRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
schedule: workflowSchedule,
|
||||
workflowName: workflow.name,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.innerJoin(workflow, eq(workflow.id, workflowSchedule.workflowId))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
isNull(workflow.archivedAt),
|
||||
eq(workflowSchedule.triggerType, 'schedule'),
|
||||
isNull(workflowSchedule.archivedAt),
|
||||
or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)),
|
||||
or(
|
||||
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ schedule: workflowSchedule })
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Cache-Control', 'no-store, max-age=0')
|
||||
|
||||
const schedules = [
|
||||
...workflowRows.map((r) => ({
|
||||
...r.schedule,
|
||||
workflowName: r.workflowName,
|
||||
})),
|
||||
...jobRows.map((r) => ({
|
||||
...r.schedule,
|
||||
workflowName: null,
|
||||
})),
|
||||
]
|
||||
|
||||
return NextResponse.json({ schedules }, { headers })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone scheduled job.
|
||||
*
|
||||
* Body: { workspaceId, title, prompt, cronExpression, timezone, lifecycle?, maxRuns?, startDate? }
|
||||
*/
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized schedule creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
createScheduleContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ error: 'Invalid request body', details: error.issues },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const {
|
||||
workspaceId,
|
||||
title,
|
||||
prompt,
|
||||
cronExpression,
|
||||
time,
|
||||
timezone,
|
||||
lifecycle,
|
||||
maxRuns,
|
||||
endsAt,
|
||||
startDate,
|
||||
contexts,
|
||||
} = parsed.data.body
|
||||
|
||||
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performCreateJob({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
title,
|
||||
prompt,
|
||||
cronExpression,
|
||||
time,
|
||||
timezone,
|
||||
lifecycle,
|
||||
maxRuns,
|
||||
endsAt,
|
||||
startDate,
|
||||
contexts,
|
||||
request: req,
|
||||
})
|
||||
if (!result.success || !result.schedule) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to create schedule' },
|
||||
{ status: result.errorCode === 'validation' ? 400 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Created job schedule ${result.schedule.id}`, {
|
||||
title,
|
||||
cronExpression,
|
||||
timezone,
|
||||
lifecycle,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
schedule: {
|
||||
id: result.schedule.id,
|
||||
status: result.schedule.status,
|
||||
cronExpression: result.schedule.cronExpression,
|
||||
nextRunAt: result.schedule.nextRunAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to create schedule' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user