Skip to content

Commit ac03d72

Browse files
committed
refactor(triggers): route legacy schedule and webhook toggles through the trigger use case
PUT /api/schedules/[id] and PATCH /api/webhooks/[id] now call setWorkflowTriggerEnabled, which owns authorization, the workflow lock, the triggers.webhook gate on re-enabling, and audit. Both keep their response shapes. The id must name the live deployment's row, so these routes can no longer revive a candidate, retired, or older-version registration, and the webhook route no longer writes an arbitrary failedCount (failedCount: 0 is still accepted and ignored). Webhook re-enable and disable are now audited. Removes the mock-based route tests the integration suite replaces.
1 parent 72bd2fe commit ac03d72

8 files changed

Lines changed: 104 additions & 463 deletions

File tree

‎apps/sim/app/api/schedules/[id]/route.test.ts‎

Lines changed: 0 additions & 126 deletions
This file was deleted.

‎apps/sim/app/api/schedules/[id]/route.ts‎

Lines changed: 51 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { workflowSchedule } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import {
6-
assertWorkflowMutable,
7-
authorizeWorkflowByWorkspacePermission,
8-
WorkflowLockedError,
9-
} from '@sim/platform-authz/workflow'
5+
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
106
import { and, eq, isNull } from 'drizzle-orm'
117
import { type NextRequest, NextResponse } from 'next/server'
128
import { getScheduleByIdContract, updateScheduleContract } from '@/lib/api/contracts/schedules'
139
import { parseRequest } from '@/lib/api/server'
10+
import {
11+
createInternalResourceConcealmentPolicy,
12+
defineInternalJsonRoute,
13+
internalErrorResponse,
14+
internalOrchestrationErrorPolicy,
15+
internalRateLimits,
16+
internalSessionAuth,
17+
} from '@/lib/api/server/routes'
1418
import { getSession } from '@/lib/auth'
1519
import { generateRequestId } from '@/lib/core/utils/request'
1620
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
17-
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
21+
import { workflowOperations } from '@/lib/workflows/application/operations'
22+
import { setWorkflowTriggerEnabled } from '@/lib/workflows/application/triggers'
1823

1924
const logger = createLogger('ScheduleAPI')
2025

@@ -97,124 +102,48 @@ export const GET = withRouteHandler(
97102
}
98103
)
99104

100-
export const PUT = withRouteHandler(
101-
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
102-
const requestId = generateRequestId()
103-
104-
try {
105-
const session = await getSession()
106-
if (!session?.user?.id) {
107-
logger.warn('Unauthorized schedule update attempt')
108-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
109-
}
110-
111-
const parsed = await parseRequest(updateScheduleContract, request, context, {
112-
validationErrorResponse: () =>
113-
NextResponse.json({ error: 'Invalid request body' }, { status: 400 }),
114-
})
115-
if (!parsed.success) return parsed.response
116-
117-
const { id: scheduleId } = parsed.data.params
118-
const validatedBody = parsed.data.body
119-
120-
const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write')
121-
if (result instanceof NextResponse) return result
122-
const { schedule, workspaceId } = result
123-
if (schedule.workflowId) {
124-
await assertWorkflowMutable(schedule.workflowId)
105+
/**
106+
* Disables or reactivates a workflow schedule through the same use case as
107+
* `PATCH /api/v2/workflows/{workflowId}/triggers/{blockId}`. The id must name
108+
* the live deployment's row: a schedule of an older version answers 404
109+
* instead of being revived.
110+
*/
111+
export const PUT = defineInternalJsonRoute({
112+
contract: updateScheduleContract,
113+
auth: internalSessionAuth,
114+
operation: workflowOperations.setTriggerEnabled,
115+
rateLimit: internalRateLimits.none({
116+
reason: 'Schedule enablement retains its existing session admission policy.',
117+
}),
118+
errorPolicy: createInternalResourceConcealmentPolicy({
119+
notFoundMessage: 'Schedule not found',
120+
base: {
121+
project: internalOrchestrationErrorPolicy.project,
122+
unhandled: () => internalErrorResponse(500, { error: 'Failed to update schedule' }),
123+
},
124+
}),
125+
parseOptions: {
126+
validationErrorResponse: () =>
127+
NextResponse.json({ error: 'Invalid request body' }, { status: 400 }),
128+
},
129+
mapInput: ({ params, body }) => ({
130+
target: { kind: 'schedule_row' as const, scheduleId: params.id },
131+
enabled: body.action === 'reactivate',
132+
}),
133+
useCase: setWorkflowTriggerEnabled,
134+
present: ({ trigger, changed }, { input }) => {
135+
if (!input.enabled) {
136+
return {
137+
message: changed ? 'Schedule disabled successfully' : 'Schedule is already disabled',
125138
}
126-
127-
const { action } = validatedBody
128-
129-
if (action === 'disable') {
130-
if (schedule.status === 'disabled') {
131-
return NextResponse.json({ message: 'Schedule is already disabled' })
132-
}
133-
134-
await db
135-
.update(workflowSchedule)
136-
.set({ status: 'disabled', nextRunAt: null, lastQueuedAt: null, updatedAt: new Date() })
137-
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
138-
139-
logger.info(`Disabled schedule: ${scheduleId}`)
140-
141-
recordAudit({
142-
workspaceId,
143-
actorId: session.user.id,
144-
actorName: session.user.name,
145-
actorEmail: session.user.email,
146-
action: AuditAction.SCHEDULE_UPDATED,
147-
resourceType: AuditResourceType.SCHEDULE,
148-
resourceId: scheduleId,
149-
resourceName: schedule.jobTitle ?? undefined,
150-
description: `Disabled schedule "${schedule.jobTitle ?? scheduleId}"`,
151-
metadata: {
152-
operation: 'disable',
153-
sourceType: schedule.sourceType,
154-
previousStatus: schedule.status,
155-
},
156-
request,
157-
})
158-
159-
return NextResponse.json({ message: 'Schedule disabled successfully' })
160-
}
161-
162-
// reactivate
163-
if (schedule.status === 'active') {
164-
return NextResponse.json({ message: 'Schedule is already active' })
165-
}
166-
167-
if (!schedule.cronExpression) {
168-
logger.error(`Schedule has no cron expression: ${scheduleId}`)
169-
return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 })
170-
}
171-
172-
const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC')
173-
if (!cronResult.isValid || !cronResult.nextRun) {
174-
logger.error(`Invalid cron expression for schedule: ${scheduleId}`)
175-
return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 })
176-
}
177-
178-
const now = new Date()
179-
const nextRunAt = cronResult.nextRun
180-
181-
await db
182-
.update(workflowSchedule)
183-
.set({ status: 'active', failedCount: 0, infraRetryCount: 0, updatedAt: now, nextRunAt })
184-
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
185-
186-
logger.info(`Reactivated schedule: ${scheduleId}`)
187-
188-
recordAudit({
189-
workspaceId,
190-
actorId: session.user.id,
191-
actorName: session.user.name,
192-
actorEmail: session.user.email,
193-
action: AuditAction.SCHEDULE_UPDATED,
194-
resourceType: AuditResourceType.SCHEDULE,
195-
resourceId: scheduleId,
196-
resourceName: schedule.jobTitle ?? undefined,
197-
description: `Reactivated schedule "${schedule.jobTitle ?? scheduleId}"`,
198-
metadata: {
199-
operation: 'reactivate',
200-
sourceType: schedule.sourceType,
201-
cronExpression: schedule.cronExpression,
202-
timezone: schedule.timezone,
203-
},
204-
request,
205-
})
206-
207-
return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt })
208-
} catch (error) {
209-
if (error instanceof WorkflowLockedError) {
210-
return NextResponse.json({ error: error.message }, { status: error.status })
211-
}
212-
213-
logger.error('Error updating schedule', error)
214-
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 })
215139
}
216-
}
217-
)
140+
if (!changed) return { message: 'Schedule is already active' }
141+
return {
142+
message: 'Schedule activated successfully',
143+
...(trigger.nextRunAt ? { nextRunAt: trigger.nextRunAt.toISOString() } : {}),
144+
}
145+
},
146+
})
218147

219148
export const DELETE = withRouteHandler(
220149
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {

0 commit comments

Comments
 (0)