Files
simstudioai--sim/apps/sim/app/api/schedules/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

283 lines
8.5 KiB
TypeScript

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 })
}
})