@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js +61 -0
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js.map +7 -0
  4. package/dist/modules/scheduler/api/trigger/route.js +37 -37
  5. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  6. package/dist/modules/scheduler/commands/jobs.js +3 -0
  7. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  8. package/dist/modules/scheduler/commands/trigger.js +146 -0
  9. package/dist/modules/scheduler/commands/trigger.js.map +7 -0
  10. package/dist/modules/scheduler/i18n/de.json +1 -0
  11. package/dist/modules/scheduler/i18n/en.json +1 -0
  12. package/dist/modules/scheduler/i18n/es.json +1 -0
  13. package/dist/modules/scheduler/i18n/ko.json +1 -0
  14. package/dist/modules/scheduler/i18n/pl.json +1 -0
  15. package/dist/modules/scheduler/lib/commandContext.js +15 -5
  16. package/dist/modules/scheduler/lib/commandContext.js.map +2 -2
  17. package/dist/modules/scheduler/lib/scheduler-safe-commands.js +11 -4
  18. package/dist/modules/scheduler/lib/scheduler-safe-commands.js.map +2 -2
  19. package/dist/modules/scheduler/services/localSchedulerService.js +2 -2
  20. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  21. package/dist/modules/scheduler/workers/execute-schedule.worker.js +38 -11
  22. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  23. package/package.json +5 -5
  24. package/src/modules/scheduler/__integration__/TC-SCHED-009.spec.ts +98 -0
  25. package/src/modules/scheduler/api/trigger/__tests__/route.test.ts +154 -0
  26. package/src/modules/scheduler/api/trigger/route.ts +63 -47
  27. package/src/modules/scheduler/commands/__tests__/trigger.test.ts +411 -0
  28. package/src/modules/scheduler/commands/jobs.ts +1 -1
  29. package/src/modules/scheduler/commands/trigger.ts +258 -0
  30. package/src/modules/scheduler/i18n/de.json +1 -0
  31. package/src/modules/scheduler/i18n/en.json +1 -0
  32. package/src/modules/scheduler/i18n/es.json +1 -0
  33. package/src/modules/scheduler/i18n/ko.json +1 -0
  34. package/src/modules/scheduler/i18n/pl.json +1 -0
  35. package/src/modules/scheduler/lib/__tests__/commandContext.test.ts +83 -1
  36. package/src/modules/scheduler/lib/commandContext.ts +39 -3
  37. package/src/modules/scheduler/lib/scheduler-safe-commands.ts +25 -4
  38. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +1 -1
  39. package/src/modules/scheduler/services/localSchedulerService.ts +2 -2
  40. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +132 -1
  41. package/src/modules/scheduler/workers/execute-schedule.worker.ts +58 -13
@@ -0,0 +1,411 @@
1
+ export {}
2
+
3
+ const registerCommand = jest.fn()
4
+
5
+ jest.mock('@open-mercato/shared/lib/commands', () => ({
6
+ registerCommand,
7
+ }))
8
+
9
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
10
+ resolveTranslations: jest.fn().mockResolvedValue({
11
+ translate: (_key: string, fallback?: string) => fallback ?? _key,
12
+ }),
13
+ }))
14
+
15
+ const enqueue = jest.fn()
16
+ const close = jest.fn()
17
+ const createQueue = jest.fn(() => ({ enqueue, close }))
18
+
19
+ // The bare `@open-mercato/queue` specifier is not resolvable under this package's
20
+ // jest moduleNameMapper (only its subpaths are), matching how the scheduler worker
21
+ // test mocks it.
22
+ jest.mock('@open-mercato/queue', () => ({ createQueue }), { virtual: true })
23
+
24
+ jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
25
+ getRedisUrlOrThrow: jest.fn(() => 'redis://localhost:6379'),
26
+ }))
27
+
28
+ const SCHEDULE_ID = '11111111-1111-4111-8111-111111111111'
29
+ const ACTOR_ID = '22222222-2222-4222-8222-222222222222'
30
+
31
+ function loadTriggerCommand() {
32
+ let trigger: any
33
+ jest.isolateModules(() => {
34
+ require('../trigger')
35
+ trigger = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.trigger')?.[0]
36
+ })
37
+ return trigger
38
+ }
39
+
40
+ function makeSchedule(overrides: Record<string, unknown> = {}) {
41
+ return {
42
+ id: SCHEDULE_ID,
43
+ name: 'Nightly report',
44
+ scopeType: 'tenant',
45
+ tenantId: 'tenant-a',
46
+ organizationId: null,
47
+ targetType: 'queue',
48
+ targetQueue: 'reports',
49
+ targetCommand: null,
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ function makeEm(schedule: Record<string, unknown> | null) {
55
+ return {
56
+ fork: jest.fn().mockReturnThis(),
57
+ findOne: jest.fn().mockResolvedValue(schedule),
58
+ flush: jest.fn().mockResolvedValue(undefined),
59
+ }
60
+ }
61
+
62
+ const userHasAllFeatures = jest.fn()
63
+
64
+ function makeCtx(auth: Record<string, unknown> | null, em: any, rbacService: any = { userHasAllFeatures }) {
65
+ return {
66
+ auth,
67
+ container: {
68
+ resolve: jest.fn((name: string) => (name === 'rbacService' ? rbacService : em)),
69
+ },
70
+ selectedOrganizationId: null,
71
+ } as any
72
+ }
73
+
74
+ const tenantActor = { sub: ACTOR_ID, userId: ACTOR_ID, tenantId: 'tenant-a', orgId: null, isSuperAdmin: false }
75
+
76
+ describe('scheduler.jobs.trigger', () => {
77
+ const previousStrategy = process.env.QUEUE_STRATEGY
78
+
79
+ beforeEach(() => {
80
+ jest.clearAllMocks()
81
+ jest.resetModules()
82
+ process.env.QUEUE_STRATEGY = 'async'
83
+ enqueue.mockResolvedValue('queue-job-1')
84
+ close.mockResolvedValue(undefined)
85
+ userHasAllFeatures.mockResolvedValue(true)
86
+ })
87
+
88
+ afterAll(() => {
89
+ if (previousStrategy === undefined) delete process.env.QUEUE_STRATEGY
90
+ else process.env.QUEUE_STRATEGY = previousStrategy
91
+ })
92
+
93
+ it('is not undoable, so a manual trigger mints no undo token', () => {
94
+ const trigger = loadTriggerCommand()
95
+ expect(trigger).toBeDefined()
96
+ expect(trigger.isUndoable).toBe(false)
97
+ expect(trigger.undo).toBeUndefined()
98
+ expect(trigger.redo).toBeUndefined()
99
+ })
100
+
101
+ it('enqueues the execution job and reports the queue job id', async () => {
102
+ const trigger = loadTriggerCommand()
103
+ const em = makeEm(makeSchedule())
104
+ const ctx = makeCtx(tenantActor, em)
105
+
106
+ const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
107
+
108
+ expect(result).toMatchObject({
109
+ scheduleId: SCHEDULE_ID,
110
+ scheduleName: 'Nightly report',
111
+ targetType: 'queue',
112
+ target: 'reports',
113
+ outcome: 'enqueued',
114
+ queueJobId: 'queue-job-1',
115
+ error: null,
116
+ })
117
+ expect(enqueue).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ scheduleId: SCHEDULE_ID,
120
+ triggerType: 'manual',
121
+ triggeredByUserId: ACTOR_ID,
122
+ }),
123
+ )
124
+ expect(close).toHaveBeenCalledTimes(1)
125
+ })
126
+
127
+ it('reports a command schedule target by its command id', async () => {
128
+ const trigger = loadTriggerCommand()
129
+ const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
130
+
131
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
132
+
133
+ expect(result).toMatchObject({ targetType: 'command', target: 'scheduler.test.echo', outcome: 'enqueued' })
134
+ })
135
+
136
+ it('leaves triggeredByUserId null for a key-only caller so the run keeps acting as the creator', async () => {
137
+ const trigger = loadTriggerCommand()
138
+ const em = makeEm(makeSchedule())
139
+ // No bound user: a bare key id is not a user id, so it could never satisfy the
140
+ // worker's RBAC lookup and must fall back to the schedule's creator.
141
+ const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
142
+
143
+ await trigger.execute({ id: SCHEDULE_ID }, ctx)
144
+
145
+ expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ triggeredByUserId: null }))
146
+ })
147
+
148
+ it('attributes an API key issued on a user behalf to that user', async () => {
149
+ const trigger = loadTriggerCommand()
150
+ const em = makeEm(makeSchedule())
151
+ // `resolveCommandActorUserId` reads `userId` before it considers `isApiKey`, so
152
+ // a user-bound key acts as its real user rather than falling back to the
153
+ // creator — the run is attributed to whoever it was actually made on behalf of.
154
+ const ctx = makeCtx(
155
+ { sub: 'api-key-1', userId: ACTOR_ID, tenantId: 'tenant-a', orgId: null, isApiKey: true },
156
+ em,
157
+ )
158
+
159
+ await trigger.execute({ id: SCHEDULE_ID }, ctx)
160
+
161
+ expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ triggeredByUserId: ACTOR_ID }))
162
+ })
163
+
164
+ it('returns not_found for a missing schedule without throwing', async () => {
165
+ const trigger = loadTriggerCommand()
166
+ const em = makeEm(null)
167
+
168
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
169
+
170
+ expect(result.outcome).toBe('not_found')
171
+ expect(enqueue).not.toHaveBeenCalled()
172
+ })
173
+
174
+ it('returns not_found for another tenant schedule, never confirming the id exists', async () => {
175
+ const trigger = loadTriggerCommand()
176
+ const em = makeEm(makeSchedule({ tenantId: 'tenant-b' }))
177
+
178
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
179
+
180
+ expect(result.outcome).toBe('not_found')
181
+ expect(result.scheduleName).toBeNull()
182
+ expect(result.target).toBeNull()
183
+ expect(enqueue).not.toHaveBeenCalled()
184
+ })
185
+
186
+ it('returns forbidden for a system-scoped schedule without super-admin', async () => {
187
+ const trigger = loadTriggerCommand()
188
+ const em = makeEm(makeSchedule({ scopeType: 'system', tenantId: null }))
189
+
190
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
191
+
192
+ expect(result.outcome).toBe('forbidden')
193
+ expect(enqueue).not.toHaveBeenCalled()
194
+ })
195
+
196
+ it('allows a super admin to trigger a system-scoped schedule', async () => {
197
+ const trigger = loadTriggerCommand()
198
+ const em = makeEm(makeSchedule({ scopeType: 'system', tenantId: null }))
199
+ const ctx = makeCtx({ ...tenantActor, isSuperAdmin: true }, em)
200
+
201
+ const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
202
+
203
+ expect(result.outcome).toBe('enqueued')
204
+ })
205
+
206
+ it('refuses a command schedule the triggering user is not authorized to run, instead of enqueueing it', async () => {
207
+ // The worker gates the run with this same assertion and can only throw when it
208
+ // refuses, which BullMQ retries forever while the caller has already been told
209
+ // the run was enqueued. Deciding here turns that into a 403 and an audit row.
210
+ userHasAllFeatures.mockResolvedValue(false)
211
+ const trigger = loadTriggerCommand()
212
+ const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
213
+
214
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
215
+
216
+ expect(result).toMatchObject({
217
+ outcome: 'forbidden',
218
+ queueJobId: null,
219
+ error: 'Scheduled command actor is not authorized',
220
+ })
221
+ // Access to the row was granted, so naming it discloses nothing the refusal withholds.
222
+ expect(result).toMatchObject({ scheduleName: 'Nightly report', target: 'scheduler.test.echo' })
223
+ expect(enqueue).not.toHaveBeenCalled()
224
+ })
225
+
226
+ it('authorizes the actor the worker will run as, not the caller when there is no bound user', async () => {
227
+ const trigger = loadTriggerCommand()
228
+ const em = makeEm(
229
+ makeSchedule({
230
+ targetType: 'command',
231
+ targetQueue: null,
232
+ targetCommand: 'scheduler.test.echo',
233
+ createdByUserId: 'creator-1',
234
+ }),
235
+ )
236
+ // A key-only caller contributes no user id, so the run falls back to the
237
+ // creator — and the creator is who this pre-check must authorize.
238
+ const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
239
+
240
+ const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
241
+
242
+ expect(result.outcome).toBe('enqueued')
243
+ expect(userHasAllFeatures).toHaveBeenCalledWith('creator-1', ['scheduler.jobs.manage'], {
244
+ tenantId: 'tenant-a',
245
+ organizationId: null,
246
+ })
247
+ })
248
+
249
+ it('refuses a command schedule with no actor at all rather than enqueueing an unrunnable job', async () => {
250
+ const trigger = loadTriggerCommand()
251
+ const em = makeEm(
252
+ makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }),
253
+ )
254
+ const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
255
+
256
+ const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
257
+
258
+ expect(result).toMatchObject({
259
+ outcome: 'forbidden',
260
+ error: 'Scheduled command requires an authenticated actor',
261
+ })
262
+ expect(enqueue).not.toHaveBeenCalled()
263
+ })
264
+
265
+ it('reports a failing authorization lookup as failed, not as a refusal', async () => {
266
+ // An RBAC store outage is not a decision about the actor. Calling it `forbidden`
267
+ // would tell the caller they lack access they may well hold, and file an audit
268
+ // row claiming a refusal that was never made.
269
+ userHasAllFeatures.mockRejectedValue(new Error('rbac store unavailable'))
270
+ const trigger = loadTriggerCommand()
271
+ const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
272
+
273
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
274
+
275
+ expect(result).toMatchObject({ outcome: 'failed', error: 'rbac store unavailable' })
276
+ expect(enqueue).not.toHaveBeenCalled()
277
+ })
278
+
279
+ it('reports an unresolvable rbacService as failed rather than throwing away the audit row', async () => {
280
+ const trigger = loadTriggerCommand()
281
+ const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
282
+ const ctx = makeCtx(tenantActor, em)
283
+ ctx.container.resolve = jest.fn((name: string) => {
284
+ if (name === 'rbacService') throw new Error('Could not resolve rbacService')
285
+ return em
286
+ })
287
+
288
+ const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
289
+
290
+ expect(result).toMatchObject({ outcome: 'failed', error: 'Could not resolve rbacService' })
291
+ })
292
+
293
+ it('leaves a queue schedule untouched by the scheduled-command gate', async () => {
294
+ userHasAllFeatures.mockResolvedValue(false)
295
+ const trigger = loadTriggerCommand()
296
+ const em = makeEm(makeSchedule())
297
+
298
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
299
+
300
+ expect(result.outcome).toBe('enqueued')
301
+ expect(userHasAllFeatures).not.toHaveBeenCalled()
302
+ })
303
+
304
+ it('returns strategy_unsupported when the queue strategy is not async', async () => {
305
+ delete process.env.QUEUE_STRATEGY
306
+ const trigger = loadTriggerCommand()
307
+ const em = makeEm(makeSchedule())
308
+
309
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
310
+
311
+ expect(result).toMatchObject({ outcome: 'strategy_unsupported', queueJobId: null, error: null })
312
+ // Access was granted, so the schedule's details are safe to record.
313
+ expect(result.scheduleName).toBe('Nightly report')
314
+ expect(createQueue).not.toHaveBeenCalled()
315
+ })
316
+
317
+ it('returns failed and still closes the queue when the enqueue rejects', async () => {
318
+ enqueue.mockRejectedValue(new Error('redis unavailable'))
319
+ const trigger = loadTriggerCommand()
320
+ const em = makeEm(makeSchedule())
321
+
322
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
323
+
324
+ expect(result).toMatchObject({ outcome: 'failed', queueJobId: null, error: 'redis unavailable' })
325
+ expect(close).toHaveBeenCalledTimes(1)
326
+ })
327
+
328
+ it('does not fail a completed enqueue when closing the queue throws', async () => {
329
+ close.mockRejectedValue(new Error('close failed'))
330
+ const trigger = loadTriggerCommand()
331
+ const em = makeEm(makeSchedule())
332
+
333
+ const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
334
+
335
+ expect(result.outcome).toBe('enqueued')
336
+ })
337
+
338
+ describe('buildLog', () => {
339
+ it('records a successful trigger against the actor', async () => {
340
+ const trigger = loadTriggerCommand()
341
+ const ctx = makeCtx({ ...tenantActor, orgId: 'org-a' }, makeEm(makeSchedule()))
342
+ const result = {
343
+ scheduleId: SCHEDULE_ID,
344
+ scheduleName: 'Nightly report',
345
+ targetType: 'queue',
346
+ target: 'reports',
347
+ outcome: 'enqueued',
348
+ queueJobId: 'queue-job-1',
349
+ error: null,
350
+ }
351
+
352
+ const log = await trigger.buildLog({ input: { id: SCHEDULE_ID }, result, ctx, snapshots: {} })
353
+
354
+ expect(log).toMatchObject({
355
+ actionLabel: 'Trigger schedule',
356
+ resourceKind: 'scheduler.job',
357
+ resourceId: SCHEDULE_ID,
358
+ tenantId: 'tenant-a',
359
+ organizationId: 'org-a',
360
+ })
361
+ expect(log.payload).toEqual({
362
+ scheduleName: 'Nightly report',
363
+ targetType: 'queue',
364
+ target: 'reports',
365
+ outcome: 'enqueued',
366
+ queueJobId: 'queue-job-1',
367
+ error: null,
368
+ })
369
+ })
370
+
371
+ it('records a refusal against the actor and discloses nothing about the row', async () => {
372
+ const trigger = loadTriggerCommand()
373
+ const ctx = makeCtx(tenantActor, makeEm(null))
374
+ const result = {
375
+ scheduleId: SCHEDULE_ID,
376
+ scheduleName: null,
377
+ targetType: null,
378
+ target: null,
379
+ outcome: 'not_found',
380
+ queueJobId: null,
381
+ error: null,
382
+ }
383
+
384
+ const log = await trigger.buildLog({ input: { id: SCHEDULE_ID }, result, ctx, snapshots: {} })
385
+
386
+ // The requested id is recorded so that probing is traceable...
387
+ expect(log.resourceId).toBe(SCHEDULE_ID)
388
+ // ...but the scope is the caller's, never the target's, and no detail of the
389
+ // row leaks into a trail the caller can read.
390
+ expect(log.tenantId).toBe('tenant-a')
391
+ expect(log.payload).toMatchObject({ outcome: 'not_found', scheduleName: null, target: null })
392
+ })
393
+
394
+ it('always returns metadata, so no outcome can silently skip the audit row', async () => {
395
+ const trigger = loadTriggerCommand()
396
+ const ctx = makeCtx(tenantActor, makeEm(null))
397
+ const outcomes = ['enqueued', 'not_found', 'forbidden', 'strategy_unsupported', 'failed']
398
+
399
+ for (const outcome of outcomes) {
400
+ const log = await trigger.buildLog({
401
+ input: { id: SCHEDULE_ID },
402
+ result: { scheduleId: SCHEDULE_ID, scheduleName: null, targetType: null, target: null, outcome, queueJobId: null, error: null },
403
+ ctx,
404
+ snapshots: {},
405
+ })
406
+ expect(log?.skipLog).toBeUndefined()
407
+ expect(log?.resourceKind).toBe('scheduler.job')
408
+ }
409
+ })
410
+ })
411
+ })
@@ -146,7 +146,7 @@ async function assertQueueTargetPayloadAllowed(queue: string, targetPayload: Rec
146
146
  if (reason) throw new CrudHttpError(422, { error: `Invalid target payload for queue ${queue}: ${reason}` })
147
147
  }
148
148
 
149
- function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
149
+ export function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
150
150
  const auth = ctx.auth
151
151
  if (!auth) return null
152
152
  if (typeof auth.userId === 'string' && auth.userId.trim().length > 0) return auth.userId.trim()
@@ -0,0 +1,258 @@
1
+ // =============================================================================
2
+ // Manual Schedule Trigger Command — Undo Policy
3
+ // =============================================================================
4
+ //
5
+ // `scheduler.jobs.trigger` is registered with `isUndoable: false` and defines no
6
+ // `undo`/`redo`. It mutates no domain state itself: it enqueues an execution job,
7
+ // and a worker picks that job up as soon as it is free. By the time an operator
8
+ // could reach for Undo the run has already started, and whatever it did is
9
+ // reversed through the action-log entries the run itself writes — the target
10
+ // command's own `undo`, or the target queue's domain-level counter-action.
11
+ //
12
+ // Minting an undo token here would therefore offer to reverse an enqueue that no
13
+ // longer exists, while leaving the run's real effects untouched. No `undo`
14
+ // handler also means no `undoToken`, hence no `x-om-operation` header — which is
15
+ // what `TC-UNDO-001 §4` pins.
16
+ //
17
+ // The command exists for the audit trail: manual triggering is an
18
+ // authorised-looking way to make an automated job run at a moment of the actor's
19
+ // choosing, so every authenticated attempt — refusals included — must leave an
20
+ // `ActionLog` row naming the caller. The one exception is bus-wide rather than
21
+ // local: `CommandBus.execute` throws `CommandInterceptorError` before `buildLog`
22
+ // and `persistLog` run, so an attempt a `before` interceptor blocks is refused
23
+ // without a row, exactly as it is for `scheduler.jobs.create`/`.update`/`.delete`.
24
+ // =============================================================================
25
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
26
+ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
27
+ import { createQueue } from '@open-mercato/queue'
28
+ import type { Queue } from '@open-mercato/queue'
29
+ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
30
+ import { createLogger } from '@open-mercato/shared/lib/logger'
31
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
32
+ import type { EntityManager } from '@mikro-orm/core'
33
+ import { ScheduledJob } from '../data/entities.js'
34
+ import { resolveScheduleAccess } from '../lib/scheduleAccess.js'
35
+ import {
36
+ assertSchedulerSafeCommandAuthorized,
37
+ SchedulerCommandAuthorizationError,
38
+ type SchedulerCommandRbacService,
39
+ } from '../lib/scheduler-safe-commands.js'
40
+ import { resolveScheduledCommandActorUserId } from '../lib/commandContext.js'
41
+ import { resolveCommandActorUserId } from './jobs.js'
42
+ import type { ScheduleTriggerInput } from '../data/validators.js'
43
+ import type { ExecuteSchedulePayload } from '../workers/execute-schedule.worker.js'
44
+
45
+ const logger = createLogger('scheduler').child({ component: 'trigger' })
46
+
47
+ /**
48
+ * Outcome of a manual trigger attempt.
49
+ *
50
+ * `execute()` returns this for every *expected* refusal instead of throwing.
51
+ * That is a hard requirement, not a style choice: `CommandBus.execute` has no
52
+ * try/catch, so a handler that throws skips `captureAfter`, `buildLog` and
53
+ * `persistLog` entirely and no `ActionLog` row is written — losing exactly the
54
+ * entries this command exists to create. The route maps `outcome` to HTTP.
55
+ */
56
+ export type TriggerScheduleResult = {
57
+ scheduleId: string
58
+ scheduleName: string | null
59
+ targetType: 'queue' | 'command' | null
60
+ target: string | null
61
+ outcome: 'enqueued' | 'not_found' | 'forbidden' | 'strategy_unsupported' | 'failed'
62
+ queueJobId: string | null
63
+ error: string | null
64
+ }
65
+
66
+ /**
67
+ * A refused attempt records the requested id and nothing else about the row.
68
+ *
69
+ * `resolveScheduleAccess` deliberately answers `not_found` rather than 403 for
70
+ * another tenant's schedule, so that a caller cannot confirm an id exists.
71
+ * Echoing that row's name or target into the audit log would hand back exactly
72
+ * the fact the decision withholds, so schedule details are populated only once
73
+ * access has been granted.
74
+ */
75
+ function refusedTrigger(
76
+ scheduleId: string,
77
+ outcome: 'not_found' | 'forbidden',
78
+ ): TriggerScheduleResult {
79
+ return {
80
+ scheduleId,
81
+ scheduleName: null,
82
+ targetType: null,
83
+ target: null,
84
+ outcome,
85
+ queueJobId: null,
86
+ error: null,
87
+ }
88
+ }
89
+
90
+ const triggerScheduleCommand: CommandHandler<ScheduleTriggerInput, TriggerScheduleResult> = {
91
+ id: 'scheduler.jobs.trigger',
92
+ isUndoable: false,
93
+
94
+ async execute(input, ctx) {
95
+ const em = ctx.container.resolve<EntityManager>('em')
96
+
97
+ // Load by id alone, then apply isolation to the loaded row. Folding the actor's
98
+ // tenant/org into the lookup would make every system-scoped schedule unmatchable.
99
+ const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })
100
+ if (!schedule) return refusedTrigger(input.id, 'not_found')
101
+
102
+ // Single source of truth for schedule visibility, shared with update, delete,
103
+ // list and executions. Its `not_found` vs `forbidden` split is deliberate.
104
+ const access = resolveScheduleAccess(schedule, ctx.auth)
105
+ if (access === 'not_found') return refusedTrigger(input.id, 'not_found')
106
+ if (access === 'forbidden') return refusedTrigger(input.id, 'forbidden')
107
+
108
+ const details = {
109
+ scheduleId: schedule.id,
110
+ scheduleName: schedule.name ?? null,
111
+ targetType: schedule.targetType ?? null,
112
+ target:
113
+ (schedule.targetType === 'command' ? schedule.targetCommand : schedule.targetQueue) ?? null,
114
+ }
115
+
116
+ const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
117
+ if (queueStrategy !== 'async') {
118
+ return { ...details, outcome: 'strategy_unsupported', queueJobId: null, error: null }
119
+ }
120
+
121
+ // `resolveCommandActorUserId` resolves the bound user whenever the auth
122
+ // context carries one — including an API key issued on a user's behalf — and
123
+ // yields null only for a key-only context. That matters now that the worker
124
+ // authorizes and runs a manual command schedule as this identity: a bare key
125
+ // id is not a user id, so it falls back to the schedule's creator rather than
126
+ // failing an RBAC lookup that could never succeed.
127
+ const triggeredByUserId = resolveCommandActorUserId(ctx)
128
+
129
+ // Decide a command schedule's authorization here, where the audit row is
130
+ // written, rather than leaving it to the worker.
131
+ //
132
+ // The worker gates the run with the same assertion and throws when it refuses,
133
+ // and a throw inside a retrying queue is invisible: the caller has already been
134
+ // told the run was enqueued, the audit row already says `enqueued`, and BullMQ
135
+ // keeps retrying a refusal that no attempt can turn into a success. Answering
136
+ // `forbidden` now makes the refusal the caller's HTTP response and the audit
137
+ // row's outcome. The worker keeps its own gate as the authority — this decision
138
+ // can go stale between enqueue and execution — but the common case is decided
139
+ // where it can be seen.
140
+ //
141
+ // Unlike `refusedTrigger`, this refusal carries the schedule's details: access
142
+ // to the row has already been granted, so naming it discloses nothing new.
143
+ if (schedule.targetType === 'command') {
144
+ const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId })
145
+ try {
146
+ await assertSchedulerSafeCommandAuthorized({
147
+ commandId: schedule.targetCommand,
148
+ actorUserId,
149
+ tenantId: schedule.tenantId,
150
+ organizationId: schedule.organizationId,
151
+ rbacService: ctx.container.resolve<SchedulerCommandRbacService>('rbacService'),
152
+ })
153
+ } catch (error) {
154
+ // Only an authorization decision is a refusal. `userHasAllFeatures` can
155
+ // also fail because its store is down, and answering `403` to an outage
156
+ // would tell the caller they lack access they may well hold, and file an
157
+ // audit row claiming a refusal that was never decided. Those become
158
+ // `failed`, the same outcome an unreachable queue produces — still audited,
159
+ // because a handler that throws writes no row at all.
160
+ const refused = error instanceof SchedulerCommandAuthorizationError
161
+ const message = error instanceof Error ? error.message : null
162
+ if (refused) {
163
+ logger.info('Manual trigger refused by scheduled-command authorization', {
164
+ scheduleId: schedule.id,
165
+ commandId: schedule.targetCommand,
166
+ reason: message,
167
+ })
168
+ } else {
169
+ logger.error('Scheduled-command authorization check failed', {
170
+ err: error,
171
+ scheduleId: schedule.id,
172
+ commandId: schedule.targetCommand,
173
+ })
174
+ }
175
+ return {
176
+ ...details,
177
+ outcome: refused ? 'forbidden' : 'failed',
178
+ queueJobId: null,
179
+ error: message,
180
+ }
181
+ }
182
+ }
183
+
184
+ const payload: ExecuteSchedulePayload = {
185
+ scheduleId: schedule.id,
186
+ tenantId: schedule.tenantId,
187
+ organizationId: schedule.organizationId,
188
+ scopeType: schedule.scopeType,
189
+ triggerType: 'manual',
190
+ triggeredByUserId,
191
+ }
192
+
193
+ let executionQueue: Queue<ExecuteSchedulePayload> | null = null
194
+ try {
195
+ executionQueue = createQueue<ExecuteSchedulePayload>('scheduler-execution', queueStrategy, {
196
+ connection: { url: getRedisUrlOrThrow('QUEUE') },
197
+ })
198
+ const queueJobId = await executionQueue.enqueue(payload)
199
+
200
+ logger.info('Manually triggered schedule', {
201
+ scheduleId: schedule.id,
202
+ scheduleName: schedule.name,
203
+ jobId: queueJobId,
204
+ triggeredBy: ctx.auth?.sub ?? null,
205
+ })
206
+
207
+ return { ...details, outcome: 'enqueued', queueJobId, error: null }
208
+ } catch (error) {
209
+ logger.error('Manual trigger failed', { err: error, scheduleId: schedule.id })
210
+ return {
211
+ ...details,
212
+ outcome: 'failed',
213
+ queueJobId: null,
214
+ error: error instanceof Error ? error.message : null,
215
+ }
216
+ } finally {
217
+ // Cleanup runs on the failure path too — the previous route-level code closed
218
+ // the queue only after a successful enqueue, leaking a Redis connection
219
+ // otherwise. A failure to close must not turn a completed enqueue into a
220
+ // failed request, so it is reported rather than thrown.
221
+ try {
222
+ await executionQueue?.close()
223
+ } catch (closeError) {
224
+ logger.warn('Failed to close scheduler execution queue', { err: closeError })
225
+ }
226
+ }
227
+ },
228
+
229
+ async buildLog({ result, ctx }) {
230
+ const { translate } = await resolveTranslations()
231
+
232
+ return {
233
+ // Keep the label free of create/update/delete/assign verbs: the action-log
234
+ // projection derives `actionType` by string-matching it.
235
+ actionLabel: translate('scheduler.audit.trigger', 'Trigger schedule'),
236
+ resourceKind: 'scheduler.job',
237
+ // The requested id, so that probing for ids that do not exist is traceable.
238
+ resourceId: result.scheduleId,
239
+ // The actor's scope on every outcome, unlike the sibling create/update/delete
240
+ // handlers which log the schedule's. A refusal must never carry the target's
241
+ // tenant: that would leak cross-tenant existence into the caller's trail and,
242
+ // because the action-log reader filters organization_id by strict equality,
243
+ // file the row where the caller's own auditors could never read it.
244
+ tenantId: ctx.auth?.tenantId ?? null,
245
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
246
+ payload: {
247
+ scheduleName: result.scheduleName,
248
+ targetType: result.targetType,
249
+ target: result.target,
250
+ outcome: result.outcome,
251
+ queueJobId: result.queueJobId,
252
+ error: result.error,
253
+ },
254
+ }
255
+ },
256
+ }
257
+
258
+ registerCommand(triggerScheduleCommand)
@@ -7,6 +7,7 @@
7
7
  "scheduler.action.view": "Anzeigen",
8
8
  "scheduler.audit.create": "Zeitplan erstellen",
9
9
  "scheduler.audit.delete": "Zeitplan löschen",
10
+ "scheduler.audit.trigger": "Zeitplan ausführen",
10
11
  "scheduler.audit.update": "Zeitplan aktualisieren",
11
12
  "scheduler.confirm.delete": "Sind Sie sicher, dass Sie diesen Zeitplan löschen möchten?",
12
13
  "scheduler.confirm.trigger": "Sind Sie sicher, dass Sie \"{name}\" jetzt ausführen möchten?\n\nDies wird {targetType} sofort ausführen.",