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