@open-mercato/scheduler 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js +61 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js.map +7 -0
- package/dist/modules/scheduler/api/trigger/route.js +37 -37
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +3 -0
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/commands/trigger.js +146 -0
- package/dist/modules/scheduler/commands/trigger.js.map +7 -0
- package/dist/modules/scheduler/i18n/de.json +1 -0
- package/dist/modules/scheduler/i18n/en.json +1 -0
- package/dist/modules/scheduler/i18n/es.json +1 -0
- package/dist/modules/scheduler/i18n/ko.json +1 -0
- package/dist/modules/scheduler/i18n/pl.json +1 -0
- package/dist/modules/scheduler/lib/commandContext.js +15 -5
- package/dist/modules/scheduler/lib/commandContext.js.map +2 -2
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js +11 -4
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js.map +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +38 -11
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-009.spec.ts +98 -0
- package/src/modules/scheduler/api/trigger/__tests__/route.test.ts +154 -0
- package/src/modules/scheduler/api/trigger/route.ts +63 -47
- package/src/modules/scheduler/commands/__tests__/trigger.test.ts +411 -0
- package/src/modules/scheduler/commands/jobs.ts +1 -1
- package/src/modules/scheduler/commands/trigger.ts +258 -0
- package/src/modules/scheduler/i18n/de.json +1 -0
- package/src/modules/scheduler/i18n/en.json +1 -0
- package/src/modules/scheduler/i18n/es.json +1 -0
- package/src/modules/scheduler/i18n/ko.json +1 -0
- package/src/modules/scheduler/i18n/pl.json +1 -0
- package/src/modules/scheduler/lib/__tests__/commandContext.test.ts +83 -1
- package/src/modules/scheduler/lib/commandContext.ts +39 -3
- package/src/modules/scheduler/lib/scheduler-safe-commands.ts +25 -4
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +1 -1
- package/src/modules/scheduler/services/localSchedulerService.ts +2 -2
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +132 -1
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +58 -13
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
import { ACTIONS_PATH as AUDIT_ACTIONS_PATH } from '@open-mercato/core/modules/audit_logs/__integration__/helpers/auditLogsApi'
|
|
5
|
+
import {
|
|
6
|
+
SCHEDULER_TRIGGER_PATH,
|
|
7
|
+
createScheduleJob,
|
|
8
|
+
deleteScheduleJob,
|
|
9
|
+
uniqueScheduleName,
|
|
10
|
+
} from './helpers/scheduler'
|
|
11
|
+
|
|
12
|
+
const TRIGGER_COMMAND_ID = 'scheduler.jobs.trigger'
|
|
13
|
+
|
|
14
|
+
type ActionLogItem = {
|
|
15
|
+
commandId: string
|
|
16
|
+
actionLabel: string | null
|
|
17
|
+
actorUserId: string | null
|
|
18
|
+
resourceKind: string | null
|
|
19
|
+
resourceId: string | null
|
|
20
|
+
undoToken: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function findTriggerLogEntry(
|
|
24
|
+
request: APIRequestContext,
|
|
25
|
+
token: string,
|
|
26
|
+
resourceId: string,
|
|
27
|
+
): Promise<ActionLogItem | null> {
|
|
28
|
+
const query = new URLSearchParams({ resourceKind: 'scheduler.job', resourceId })
|
|
29
|
+
const response = await apiRequest(request, 'GET', `${AUDIT_ACTIONS_PATH}?${query.toString()}`, {
|
|
30
|
+
token,
|
|
31
|
+
})
|
|
32
|
+
expect(response.status()).toBe(200)
|
|
33
|
+
const body = await readJsonSafe<{ items?: ActionLogItem[] }>(response)
|
|
34
|
+
return (body?.items ?? []).find((item) => item.commandId === TRIGGER_COMMAND_ID) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* TC-SCHED-009: every authenticated manual trigger of a schedule is recorded in
|
|
39
|
+
* the action log against the calling user — including refusals.
|
|
40
|
+
*
|
|
41
|
+
* Manual triggering is an authorised-looking way to make an automated job run at
|
|
42
|
+
* a moment of the actor's choosing, so the attempt itself is the auditable event.
|
|
43
|
+
* Both assertions below are therefore independent of QUEUE_STRATEGY: under the
|
|
44
|
+
* async strategy the trigger is enqueued, under the local strategy it is refused
|
|
45
|
+
* with "async required", and either way the attempt must leave an entry.
|
|
46
|
+
*/
|
|
47
|
+
test.describe('TC-SCHED-009: manual schedule triggers are written to the action log', () => {
|
|
48
|
+
test('records a trigger attempt against the calling user', async ({ request }) => {
|
|
49
|
+
const token = await getAuthToken(request, 'admin')
|
|
50
|
+
let scheduleId: string | null = null
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
scheduleId = await createScheduleJob(request, token, {
|
|
54
|
+
name: uniqueScheduleName('Trigger-Audit'),
|
|
55
|
+
scheduleType: 'interval',
|
|
56
|
+
scheduleValue: '1h',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
|
|
60
|
+
token,
|
|
61
|
+
data: { id: scheduleId },
|
|
62
|
+
})
|
|
63
|
+
// 200 under the async strategy, 400 "async required" under local — the
|
|
64
|
+
// attempt is audited either way.
|
|
65
|
+
expect([200, 400]).toContain(response.status())
|
|
66
|
+
|
|
67
|
+
const entry = await findTriggerLogEntry(request, token, scheduleId)
|
|
68
|
+
expect(entry, 'a manual trigger writes an action-log entry').not.toBeNull()
|
|
69
|
+
expect(entry?.resourceKind).toBe('scheduler.job')
|
|
70
|
+
expect(entry?.resourceId).toBe(scheduleId)
|
|
71
|
+
expect(entry?.actorUserId, 'the entry names the caller').toBeTruthy()
|
|
72
|
+
expect(entry?.actionLabel ?? '').toMatch(/trigger/i)
|
|
73
|
+
// The enqueued run is reversed through the entries the run itself writes,
|
|
74
|
+
// so the trigger exposes no undo affordance (see TC-UNDO-001 §4).
|
|
75
|
+
expect(entry?.undoToken).toBeNull()
|
|
76
|
+
} finally {
|
|
77
|
+
await deleteScheduleJob(request, token, scheduleId)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('records a refused trigger for a schedule id that does not exist', async ({ request }) => {
|
|
82
|
+
const token = await getAuthToken(request, 'admin')
|
|
83
|
+
// A well-formed UUID that no schedule uses: the request passes validation and
|
|
84
|
+
// is refused on lookup, which is exactly the probing attempt worth recording.
|
|
85
|
+
const missingScheduleId = '00000000-0000-4000-8000-0000000009de'
|
|
86
|
+
|
|
87
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
|
|
88
|
+
token,
|
|
89
|
+
data: { id: missingScheduleId },
|
|
90
|
+
})
|
|
91
|
+
expect(response.status()).toBe(404)
|
|
92
|
+
|
|
93
|
+
const entry = await findTriggerLogEntry(request, token, missingScheduleId)
|
|
94
|
+
expect(entry, 'a refused trigger still writes an action-log entry').not.toBeNull()
|
|
95
|
+
expect(entry?.resourceId).toBe(missingScheduleId)
|
|
96
|
+
expect(entry?.actorUserId, 'the refusal names the caller').toBeTruthy()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server'
|
|
2
|
+
import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
|
|
3
|
+
import type { TriggerScheduleResult } from '../../../commands/trigger'
|
|
4
|
+
|
|
5
|
+
const scheduleId = '44444444-4444-4444-8444-444444444444'
|
|
6
|
+
const userId = '33333333-3333-4333-8333-333333333333'
|
|
7
|
+
const tenantId = '11111111-1111-4111-8111-111111111111'
|
|
8
|
+
const organizationId = '22222222-2222-4222-8222-222222222222'
|
|
9
|
+
|
|
10
|
+
const commandBusExecuteMock = jest.fn()
|
|
11
|
+
const getAuthFromRequestMock = jest.fn()
|
|
12
|
+
|
|
13
|
+
const container = {
|
|
14
|
+
resolve: jest.fn((token: string) => {
|
|
15
|
+
if (token === 'commandBus') return { execute: commandBusExecuteMock }
|
|
16
|
+
return {}
|
|
17
|
+
}),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
21
|
+
createRequestContainer: jest.fn(async () => container),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
25
|
+
getAuthFromRequest: (...args: unknown[]) => getAuthFromRequestMock(...args),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
29
|
+
resolveTranslations: async () => ({
|
|
30
|
+
translate: (key: string, fallback?: string) => fallback ?? key,
|
|
31
|
+
}),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
import { POST } from '../route'
|
|
35
|
+
|
|
36
|
+
function request(body: unknown = { id: scheduleId }): NextRequest {
|
|
37
|
+
return new NextRequest('http://localhost/api/scheduler/trigger', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function outcome(partial: Partial<TriggerScheduleResult>): TriggerScheduleResult {
|
|
45
|
+
return {
|
|
46
|
+
scheduleId,
|
|
47
|
+
scheduleName: null,
|
|
48
|
+
targetType: null,
|
|
49
|
+
target: null,
|
|
50
|
+
outcome: 'enqueued',
|
|
51
|
+
queueJobId: null,
|
|
52
|
+
error: null,
|
|
53
|
+
...partial,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('POST /api/scheduler/trigger', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
getAuthFromRequestMock.mockResolvedValue({ sub: userId, tenantId, orgId: organizationId })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('answers 401 without dispatching the command when there is no actor', async () => {
|
|
64
|
+
getAuthFromRequestMock.mockResolvedValue(null)
|
|
65
|
+
|
|
66
|
+
const response = await POST(request())
|
|
67
|
+
|
|
68
|
+
expect(response.status).toBe(401)
|
|
69
|
+
expect(commandBusExecuteMock).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('answers 400 without dispatching the command when the body is malformed', async () => {
|
|
73
|
+
const response = await POST(request({ id: 'not-a-uuid' }))
|
|
74
|
+
|
|
75
|
+
expect(response.status).toBe(400)
|
|
76
|
+
expect(commandBusExecuteMock).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
['enqueued' as const, 200],
|
|
81
|
+
['not_found' as const, 404],
|
|
82
|
+
['forbidden' as const, 403],
|
|
83
|
+
['strategy_unsupported' as const, 400],
|
|
84
|
+
['failed' as const, 400],
|
|
85
|
+
])('maps the %s outcome to HTTP %i', async (value, status) => {
|
|
86
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
87
|
+
result: outcome({ outcome: value, queueJobId: value === 'enqueued' ? 'job-1' : null }),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const response = await POST(request())
|
|
91
|
+
|
|
92
|
+
expect(response.status).toBe(status)
|
|
93
|
+
expect(commandBusExecuteMock).toHaveBeenCalledWith(
|
|
94
|
+
'scheduler.jobs.trigger',
|
|
95
|
+
expect.objectContaining({ input: { id: scheduleId } }),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns the queue job id on a successful enqueue', async () => {
|
|
100
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
101
|
+
result: outcome({ outcome: 'enqueued', queueJobId: 'job-1' }),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const response = await POST(request())
|
|
105
|
+
|
|
106
|
+
expect(await response.json()).toEqual(expect.objectContaining({ ok: true, jobId: 'job-1' }))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('surfaces the failure reason a failed outcome carries', async () => {
|
|
110
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
111
|
+
result: outcome({ outcome: 'failed', error: 'Redis unavailable' }),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const response = await POST(request())
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(400)
|
|
117
|
+
expect(await response.json()).toEqual({ error: 'Redis unavailable' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A `before` interceptor can block the dispatch with a deliberate status and body;
|
|
121
|
+
// flattening that to the generic 400 would make a business rejection look like a
|
|
122
|
+
// malformed request, unlike every CRUD route.
|
|
123
|
+
it('passes an interceptor rejection through with its own status and body', async () => {
|
|
124
|
+
commandBusExecuteMock.mockRejectedValue(
|
|
125
|
+
new CommandInterceptorError('Period is closed', {
|
|
126
|
+
status: 409,
|
|
127
|
+
body: { error: 'Period is closed', reason: 'locked-period' },
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const response = await POST(request())
|
|
132
|
+
|
|
133
|
+
expect(response.status).toBe(409)
|
|
134
|
+
expect(await response.json()).toEqual({ error: 'Period is closed', reason: 'locked-period' })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('keeps the generic 400 for an interceptor rejection that declares no status', async () => {
|
|
138
|
+
commandBusExecuteMock.mockRejectedValue(new CommandInterceptorError('Blocked'))
|
|
139
|
+
|
|
140
|
+
const response = await POST(request())
|
|
141
|
+
|
|
142
|
+
expect(response.status).toBe(400)
|
|
143
|
+
expect(await response.json()).toEqual({ error: 'Blocked' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('answers 400 when the bus throws after the enqueue, e.g. a failing audit store', async () => {
|
|
147
|
+
commandBusExecuteMock.mockRejectedValue(new Error('action log write failed'))
|
|
148
|
+
|
|
149
|
+
const response = await POST(request())
|
|
150
|
+
|
|
151
|
+
expect(response.status).toBe(400)
|
|
152
|
+
expect(await response.json()).toEqual({ error: 'action log write failed' })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -2,14 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
-
import type { EntityManager } from '@mikro-orm/core'
|
|
6
5
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { ScheduledJob } from '../../data/entities.js'
|
|
10
|
-
import { resolveScheduleAccess } from '../../lib/scheduleAccess.js'
|
|
6
|
+
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
7
|
+
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
11
8
|
import { scheduleTriggerSchema } from '../../data/validators.js'
|
|
12
|
-
import type {
|
|
9
|
+
import type { TriggerScheduleResult } from '../../commands/trigger.js'
|
|
10
|
+
import type { ScheduleTriggerInput } from '../../data/validators.js'
|
|
13
11
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
14
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
15
13
|
|
|
@@ -23,46 +21,64 @@ export const metadata = {
|
|
|
23
21
|
/**
|
|
24
22
|
* POST /api/scheduler/trigger
|
|
25
23
|
* Manually trigger a schedule
|
|
26
|
-
*
|
|
24
|
+
*
|
|
27
25
|
* This enqueues the schedule execution job in BullMQ.
|
|
28
26
|
* Execution history is tracked in BullMQ job state.
|
|
27
|
+
*
|
|
28
|
+
* The handler is a thin mapper: `scheduler.jobs.trigger` owns loading the
|
|
29
|
+
* schedule, deciding access, checking the queue strategy and enqueueing, so that
|
|
30
|
+
* every authenticated attempt — refusals included — leaves an action-log entry
|
|
31
|
+
* attributed to the caller. Expected refusals come back as an `outcome`, never as
|
|
32
|
+
* an exception, because a throwing handler would skip the audit write entirely.
|
|
29
33
|
*/
|
|
30
34
|
export async function POST(req: NextRequest) {
|
|
31
35
|
const { translate } = await resolveTranslations()
|
|
32
36
|
const auth = await getAuthFromRequest(req)
|
|
37
|
+
// No actor means nothing to attribute, so this is the one path that
|
|
38
|
+
// legitimately leaves no action-log entry.
|
|
33
39
|
if (!auth?.sub) {
|
|
34
40
|
return NextResponse.json({ error: translate('scheduler.error.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
const container = await createRequestContainer()
|
|
38
|
-
const em = container.resolve<EntityManager>('em')
|
|
39
44
|
|
|
40
45
|
try {
|
|
41
46
|
const body = await req.json()
|
|
47
|
+
// Parsed before the command runs, so a malformed body still answers 400 with
|
|
48
|
+
// the validation message and the command never sees unvalidated input.
|
|
42
49
|
const input = scheduleTriggerSchema.parse(body)
|
|
43
50
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
// Null organization scope is the supported non-CRUD shape (see
|
|
52
|
+
// `ensureOrganizationScope`), and the scheduler's own worker and
|
|
53
|
+
// `localSchedulerService` build their contexts the same way. The trigger
|
|
54
|
+
// command performs its isolation through `resolveScheduleAccess` on the loaded
|
|
55
|
+
// row, so the only consumer of these fields is the action-log row's scope.
|
|
56
|
+
const ctx: CommandRuntimeContext = {
|
|
57
|
+
container,
|
|
58
|
+
auth,
|
|
59
|
+
organizationScope: null,
|
|
60
|
+
selectedOrganizationId: auth.orgId ?? null,
|
|
61
|
+
organizationIds: auth.orgId ? [auth.orgId] : null,
|
|
62
|
+
request: req,
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
const
|
|
53
|
-
|
|
65
|
+
const commandBus = container.resolve<CommandBus>('commandBus')
|
|
66
|
+
const { result } = await commandBus.execute<ScheduleTriggerInput, TriggerScheduleResult>(
|
|
67
|
+
'scheduler.jobs.trigger',
|
|
68
|
+
{ input, ctx },
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (result.outcome === 'not_found') {
|
|
54
72
|
return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
|
|
55
73
|
}
|
|
56
|
-
|
|
74
|
+
|
|
75
|
+
if (result.outcome === 'forbidden') {
|
|
57
76
|
return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
|
|
62
|
-
|
|
63
|
-
if (queueStrategy !== 'async') {
|
|
79
|
+
if (result.outcome === 'strategy_unsupported') {
|
|
64
80
|
return NextResponse.json(
|
|
65
|
-
{
|
|
81
|
+
{
|
|
66
82
|
error: translate('scheduler.error.trigger_async_required', 'Manual trigger requires QUEUE_STRATEGY=async'),
|
|
67
83
|
message: translate('scheduler.error.trigger_async_hint', 'Execution history and manual triggers are only available with BullMQ (async strategy)')
|
|
68
84
|
},
|
|
@@ -70,37 +86,31 @@ export async function POST(req: NextRequest) {
|
|
|
70
86
|
)
|
|
71
87
|
}
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const payload: ExecuteSchedulePayload = {
|
|
79
|
-
scheduleId: schedule.id,
|
|
80
|
-
tenantId: schedule.tenantId,
|
|
81
|
-
organizationId: schedule.organizationId,
|
|
82
|
-
scopeType: schedule.scopeType,
|
|
83
|
-
triggerType: 'manual',
|
|
84
|
-
triggeredByUserId: auth.sub,
|
|
89
|
+
if (result.outcome === 'failed') {
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: result.error ?? translate('scheduler.error.trigger_failed', 'Failed to trigger schedule') },
|
|
92
|
+
{ status: 400 }
|
|
93
|
+
)
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
const jobId = await executionQueue.enqueue(payload)
|
|
88
|
-
await executionQueue.close()
|
|
89
|
-
|
|
90
|
-
logger.info('Manually triggered schedule', {
|
|
91
|
-
scheduleId: schedule.id,
|
|
92
|
-
scheduleName: schedule.name,
|
|
93
|
-
jobId,
|
|
94
|
-
triggeredBy: auth.sub,
|
|
95
|
-
})
|
|
96
|
-
|
|
97
96
|
return NextResponse.json({
|
|
98
97
|
ok: true,
|
|
99
|
-
jobId, // BullMQ job ID
|
|
98
|
+
jobId: result.queueJobId, // BullMQ job ID
|
|
100
99
|
message: translate('scheduler.success.triggered', 'Schedule queued for execution'),
|
|
101
100
|
})
|
|
102
101
|
|
|
103
102
|
} catch (error) {
|
|
103
|
+
// A command interceptor that blocked with an explicit status is a deliberate
|
|
104
|
+
// business rejection, not a malformed request — surface its status and body
|
|
105
|
+
// instead of flattening it to the generic 400 below.
|
|
106
|
+
const interceptorRejection = getCommandInterceptorHttpRejection(error)
|
|
107
|
+
if (interceptorRejection) {
|
|
108
|
+
logger.warn('Manual trigger blocked by interceptor', { err: error })
|
|
109
|
+
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
110
|
+
}
|
|
111
|
+
// The action log is written after the enqueue, and the bus does not guard that
|
|
112
|
+
// write, so a failing audit store surfaces here with the job already queued.
|
|
113
|
+
// Record it loudly: the run stays traceable even when its entry was lost.
|
|
104
114
|
logger.error('Manual trigger failed', { err: error })
|
|
105
115
|
return NextResponse.json(
|
|
106
116
|
{ error: error instanceof Error ? error.message : translate('scheduler.error.trigger_failed', 'Failed to trigger schedule') },
|
|
@@ -125,12 +135,12 @@ const errorResponseSchema = z.object({
|
|
|
125
135
|
export const openApi: OpenApiRouteDoc = {
|
|
126
136
|
tag: 'Scheduler',
|
|
127
137
|
summary: 'Manually trigger a schedule',
|
|
128
|
-
description: 'Execute a schedule immediately by enqueueing it in the scheduler-execution queue. Requires QUEUE_STRATEGY=async.',
|
|
138
|
+
description: 'Execute a schedule immediately by enqueueing it in the scheduler-execution queue. Requires QUEUE_STRATEGY=async. Every authenticated attempt, including refusals, is recorded in the action log against the calling user; the one exception is an attempt a before command interceptor blocks, which is rejected before the command runs and therefore writes no row.',
|
|
129
139
|
methods: {
|
|
130
140
|
POST: {
|
|
131
141
|
operationId: 'triggerScheduledJob',
|
|
132
142
|
summary: 'Manually trigger a schedule',
|
|
133
|
-
description: 'Executes a scheduled job immediately, bypassing the scheduled time. Only works with async queue strategy.',
|
|
143
|
+
description: 'Executes a scheduled job immediately, bypassing the scheduled time. Only works with async queue strategy. The attempt is audited against the calling user whether it succeeds, is refused, or fails, except when a before command interceptor blocks it before the command runs.',
|
|
134
144
|
requestBody: {
|
|
135
145
|
schema: scheduleTriggerSchema,
|
|
136
146
|
contentType: 'application/json',
|
|
@@ -147,6 +157,12 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
147
157
|
{ status: 401, description: 'Unauthorized', schema: errorResponseSchema },
|
|
148
158
|
{ status: 403, description: 'Access denied', schema: errorResponseSchema },
|
|
149
159
|
{ status: 404, description: 'Schedule not found', schema: errorResponseSchema },
|
|
160
|
+
{
|
|
161
|
+
status: 409,
|
|
162
|
+
description:
|
|
163
|
+
'Trigger deliberately blocked by a before command interceptor. The interceptor chooses the status (any 4xx/5xx) and may replace the body.',
|
|
164
|
+
schema: errorResponseSchema,
|
|
165
|
+
},
|
|
150
166
|
],
|
|
151
167
|
},
|
|
152
168
|
},
|